BlogIaC

Terraform remote state and locking on S3 and DynamoDB

Move Terraform state off your laptop into an encrypted S3 or GCS backend with locking, so two applies never race and corrupt your infrastructure.

May 5, 2026·4 min readIntermediate·By the SecOpsLog team · command-tested

Terraform's default is a terraform.tfstate file on your laptop. That file is the inventory of every resource id your stack owns — often including database passwords and API keys in plaintext — and it has no locking. Two engineers running apply at the same time can interleave writes and leave you with a state file that no longer matches reality. Remote state fixes all three problems: shared access, encryption at rest, and a distributed lock so only one mutation runs at a time.

This note walks through an S3 backend with DynamoDB locking, the IAM policy shape that keeps the bucket readable only to CI and humans who need it, and the one-time migration from local state. If you are still wiring your first VPC, start with Terraform and return here before anyone else touches the stack.

Remote state lifecycle

Backend config lives in code. The lock table prevents concurrent writers. Versioning on the bucket is your undo button after a bad apply.

1Create bucketSSE + versioning + block public2Create lock tableDynamoDB LockID hash key3backend "s3" {}bucket, key, dynamodb_table4terraform init-migrate-state copies local file5Plan in CIrole assumes state read/write6Apply acquireslockothers wait or fail fast7Version rollbackS3 object version if needed

Configure the S3 backend

The state key should encode environment and stack name so prod network and staging network never share a file. Enable encrypt = true so SSE-S3 or SSE-KMS protects the object. Pair the bucket with a DynamoDB table whose sole job is holding lock rows — not application data.

IAM for state access should follow least privilege: CI roles get s3:ListBucket on the prefix, s3:GetObject/PutObject on state keys, and dynamodb:GetItem/PutItem/DeleteItem on the lock table — nothing broader. Human break-glass roles can be narrower still, scoped to non-prod prefixes until an incident expands scope. Turn on S3 bucket versioning and MFA delete if your org allows it; the worst Terraform disaster is often recoverable from yesterday's state object if you notice drift before someone runs apply again.

backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "prod/network/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "tf-locks"
encrypt = true
}
}

Locking with DynamoDB

When Terraform starts an apply, it writes a lock row keyed by the state path. A second process sees the lock and stops instead of corrupting state. PAY_PER_REQUEST billing is fine — lock traffic is tiny. If a CI job dies mid-apply, use terraform force-unlock only after confirming no apply is actually running.

Alternative backends — Terraform Cloud, GCS with native locking, Azure Blob with lease APIs — solve the same problem with different ops models. S3 plus DynamoDB remains the default on AWS because every account already has the primitives and the pattern is well documented in runbooks. Whatever backend you pick, document the lock semantics in your platform wiki so on-call knows whether a stuck plan is safe to unlock.

create-lock-table.sh
aws dynamodb create-table \
--table-name tf-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
bash — migrate local state oncelive
terraform init -migrate-state
Do you want to copy existing state to the new backend?
Successfully configured the backend "s3"!
aws s3api list-object-versions --bucket acme-tfstate \
--prefix prod/network/terraform.tfstate
version history proves rollback is possible
Local vs remote state
Local tfstate
Single laptop owns truth
No lock — race on apply
Secrets in plaintext JSON
Lost if disk dies
S3 + DynamoDB
Team + CI share one source
Lock serializes writers
SSE encryption at rest
Versioned, auditable objects
State is a secret — treat the bucket like a vault
Restrict `s3:GetObject` and `dynamodb:*` on the lock table to CI roles and break-glass humans. Enable bucket versioning before your first prod apply. Never commit `.tfstate` to Git — scanners will find it, and so will attackers.

Where this goes next

Remote state is the foundation for team Terraform. Layer on Checkov in CI to catch public buckets before apply, split environments with separate state keys or workspaces, and pin modules so plans stay reproducible. The Terraform course covers backends, workspaces, and CI patterns end to end.

Go deeper in a courseTerraformFrom first resource to remote state, modules, and CI/CD apply pipelines.View course

Related posts