Mike Barkas

Software and DevOps

Mike Barkas

Mike Barkas

Software and DevOps

Storing Terraform State in S3

August 14, 2026

Terraform keeps track of everything it manages in a state file, terraform.tfstate. By default that file sits on your local disk. That works for a first experiment, but it causes problems quickly:

  • It can contain secrets. Database passwords, private IPs, and resource IDs are stored in plain text.
  • It lives on one machine. If the laptop dies, Terraform loses track of your infrastructure.
  • There’s no locking. Two people (or two CI jobs) running apply at the same time can corrupt it.
  • There’s no history. A bad write can’t be rolled back.

Storing state in an S3 bucket solves all four: the bucket is encrypted, versioned, access-controlled, and supports locking.

Here is an example in a couple easy steps.

Step 1: Create the state bucket

The bucket has to exist before Terraform can store state in it, so create it in a small, separate configuration that keeps its own state locally. You only run this once.

# bootstrap/main.tf
provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "state" {
  bucket = "myproject-terraform-state" # must be globally unique

  lifecycle {
    prevent_destroy = true
  }
}

# Keep every version of the state file so you can roll back
resource "aws_s3_bucket_versioning" "state" {
  bucket = aws_s3_bucket.state.id
  versioning_configuration {
    status = "Enabled"
  }
}

# Encrypt state at rest
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
  bucket = aws_s3_bucket.state.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

# State should never be public
resource "aws_s3_bucket_public_access_block" "state" {
  bucket                  = aws_s3_bucket.state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Then you can apply it like this:

cd bootstrap
terraform init
terraform apply

A few notes:

  • prevent_destroy stops a stray terraform destroy from deleting the bucket that holds all your state.
  • aws:kms uses the AWS-managed KMS key. S3 already encrypts new objects by default, but KMS adds an audit trail in CloudTrail and lets you control who can decrypt. AES256 works too if you want the simplest option.

Step 2: Point your project at the bucket

In the project whose state you want to move, add a backend block:

# main.tf
terraform {
  required_version = ">= 1.10"

  backend "s3" {
    bucket       = "myproject-terraform-state"
    key          = "myapp/api/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}
  • key is the path of the state file inside the bucket. Give each project its own key, and one bucket can hold state for all of them.
  • encrypt = true makes sure the state is encrypted when it’s written.
  • use_lockfile = true turns on S3-native locking (Terraform 1.10+). While a plan or apply runs, Terraform writes a .tflock file next to the state, and anyone else has to wait. Older guides use a DynamoDB table for locking; that approach is now deprecated.

Step 3: Migrate the existing state

terraform init -migrate-state

Terraform finds your local terraform.tfstate, asks to copy it to S3, and from then on reads and writes state in the bucket. Run terraform plan afterward: it should show no changes.

Once that’s confirmed, delete the local terraform.tfstate and terraform.tfstate.backup files.

Make sure they were never committed:
# .gitignore
*.tfstate
*.tfstate.*
.terraform/

Bonus: Reading outputs from another project

With state in S3, one project can read another’s outputs. For example, a DNS configuration can look up the public IP that the API project created:

data "terraform_remote_state" "api" {
  backend = "s3"
  config = {
    bucket = "myproject-terraform-state"
    key    = "myapp/api/terraform.tfstate"
    region = "us-east-1"
  }
}

# Use it like this:
# data.terraform_remote_state.api.outputs.public_ip

This replaces fragile relative paths like ../api/terraform.tfstate, and it works the same on any machine or in CI.

Lock down access

Anyone who can read the bucket can read every secret in your state, so give access only to the people and CI roles that run Terraform. A minimal IAM policy for one project:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::myproject-terraform-state"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": [
        "arn:aws:s3:::myproject-terraform-state/myapp/api/terraform.tfstate",
        "arn:aws:s3:::myproject-terraform-state/myapp/api/terraform.tfstate.tflock"
      ]
    }
  ]
}

Summary

Problem with local stateS3 feature that fixes it
Secrets in plain textServer-side encryption (KMS) and IAM access control
Lives on one laptopStored centrally, available to your team and CI
No lockinguse_lockfile = true
No historyBucket versioning

Create the bucket once, add a backend block to each project, and run terraform init -migrate-state.