Happy Dussehra! May good always win. Happy Diwali! Wishing you a bright year ahead. Merry Christmas! Warm wishes from Vizag. Happy New Year! Here’s to a great year ahead.

Archive · DevOps

Terraform Remote State and Locking on AWS

Store Terraform state in a hardened S3 bucket with DynamoDB locking, bootstrap it safely, split states by environment and recover from locks and corruption.

3 min read

  • Terraform 1.2
  • Amazon S3
  • Amazon DynamoDB
  • KMS

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 run terraform plan to 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 apply locally with someone’s admin credentials; use a CI role so every change is attributable.