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
applyat 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_destroystops a strayterraform destroyfrom deleting the bucket that holds all your state.aws:kmsuses 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.AES256works 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
}
}
keyis 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 = truemakes sure the state is encrypted when it’s written.use_lockfile = trueturns on S3-native locking (Terraform 1.10+). While a plan or apply runs, Terraform writes a.tflockfile 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.
# .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 state | S3 feature that fixes it |
|---|---|
| Secrets in plain text | Server-side encryption (KMS) and IAM access control |
| Lives on one laptop | Stored centrally, available to your team and CI |
| No locking | use_lockfile = true |
| No history | Bucket versioning |
Create the bucket once, add a backend block to each project, and run terraform init -migrate-state.
