State is Terraform’s record of what it manages. On a laptop it is a local file; on a team that is a recipe for overwritten changes and lost state. A remote backend stores the state centrally and a lock stops two people applying at once. On AWS the standard pattern is an S3 bucket for the state and a DynamoDB table for the lock.
Versions in use on this date. Terraform 1.2 with the s3 backend. The DynamoDB table is only used for locking, and this remains the supported pattern at present.
Layout
flowchart TB
D1[Engineer laptop] --> TF
CI[CI pipeline] --> TF[terraform plan or apply]
TF -->|1 acquire lock| DDB[(DynamoDB table terraform-locks<br/>key LockID)]
TF -->|2 read state| S3[(S3 bucket acme-tfstate<br/>versioning on, encrypted, private)]
TF -->|3 change infrastructure| AWS[AWS resources]
TF -->|4 write state| S3
TF -->|5 release lock| DDB
S3 --> VER[Previous versions kept for recovery]
IAM[IAM role for Terraform] -.-> TF
Bootstrap the backend
The bucket and table must exist before terraform init. Create them once, by script or a tiny separate Terraform configuration with local state:
ACCOUNT=111122223333
BUCKET=acme-tfstate-$ACCOUNT
aws s3api create-bucket --bucket $BUCKET --region ap-south-1 \
--create-bucket-configuration LocationConstraint=ap-south-1
aws s3api put-bucket-versioning --bucket $BUCKET --versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket $BUCKET --public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-encryption --bucket $BUCKET --server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"}}]}'
aws dynamodb create-table --table-name terraform-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST --region ap-south-1
Add a bucket policy that denies non-TLS access (aws:SecureTransport = false), and enable access logging or CloudTrail data events on the bucket if your policy requires an audit trail.
Configure the backend
terraform {
backend "s3" {
bucket = "acme-tfstate-111122223333"
key = "shop/dev/network/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Then terraform init. When migrating an existing local state, terraform init -migrate-state (or -reconfigure to ignore the old one) asks before copying it. Backend blocks cannot use variables, so pass environment differences with -backend-config files:
terraform init -backend-config=env/dev.backend.hcl
IAM permissions for the backend
The role that runs Terraform needs, for the state location only:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::acme-tfstate-111122223333" },
{ "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": "arn:aws:s3:::acme-tfstate-111122223333/shop/dev/*" },
{ "Effect": "Allow", "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"], "Resource": "arn:aws:dynamodb:ap-south-1:111122223333:table/terraform-locks" }
]
}
Scope the object path per environment so a dev role cannot read production state, and add kms:Decrypt and kms:GenerateDataKey if you use a customer-managed key.
Splitting state
One giant state is slow and risky; many tiny ones are hard to coordinate. A practical split:
| State key | Contents | Changes |
|---|---|---|
shop/prod/network |
VPC, subnets, routes | Rarely |
shop/prod/data |
RDS, DynamoDB | Carefully |
shop/prod/app |
ECS/EKS services, load balancers | Often |
Share values between states with outputs and the terraform_remote_state data source, or look resources up by tag with data sources (fewer hard dependencies).
Operations and recovery
- Stuck lock: a killed run can leave an item in the table. Check nobody is applying, then
terraform force-unlock <LOCK_ID>(the ID is in the error message). - Bad apply or corrupted state: with bucket versioning on, list versions (
aws s3api list-object-versions --bucket ... --prefix shop/prod/app/) and restore a previous one, then runterraform planto see the drift before doing anything else. - Inspect state:
terraform state list,terraform state show <addr>; avoid editing the JSON by hand. - Access: state can contain passwords, keys and database endpoints. Limit read access as strictly as you limit production itself.
- Backups: S3 versioning plus a lifecycle rule to expire old versions after, say, 90 days.
Common mistakes
- Putting the state bucket in the same Terraform configuration it stores state for, then destroying it. Keep the backend resources separate and protected.
- Sharing one state across environments, so a dev change can plan against production.
- Running
applylocally with someone’s admin credentials; use a CI role so every change is attributable.