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.
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.
Backend config lives in code. The lock table prevents concurrent writers. Versioning on the bucket is your undo button after a bad apply.
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.
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.
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
terraform init -migrate-stateDo 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.tfstateversion history proves rollback is possibleWhere 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