Terraform’s state file maps your code to real resource IDs. Once two people, or one person and a pipeline, run Terraform on the same project, the state must live in a shared, versioned, locked location. For a decade the AWS recipe was an S3 bucket plus a DynamoDB table for locks. Since Terraform 1.10 the S3 backend can lock natively with a .tflock object written through S3 conditional writes (use_lockfile = true), and Terraform 1.11 deprecated the DynamoDB option. This guide sets up the modern backend from scratch, migrates an existing DynamoDB-locked project, then covers the team practices around it: one state per environment, cross-stack outputs, CI with OIDC, and recovery procedures.
Prerequisites: Terraform 1.10 or newer (terraform version), AWS CLI configured with rights on S3, KMS and IAM, and a project with local state to migrate (our Terraform overview provides one). The shorter conceptual introduction lives in Terraform remote state on S3; this article is the operational deep dive.
How native S3 locking works
When a Terraform operation needs the lock, the backend performs a PutObject of <key>.tflock with the header If-None-Match: *. S3 accepts the write only if the object does not exist yet, atomically; a second writer receives a 412 Precondition Failed and Terraform reports “Error acquiring the state lock” with the lock ID, who holds it and since when. On completion the lock object is deleted. No extra service, no table to pay for, no IAM permissions on DynamoDB.
| DynamoDB locking (legacy) | S3 native locking (1.10+) | |
|---|---|---|
| Extra resources | DynamoDB table with LockID key | None |
| Backend arguments | dynamodb_table (deprecated in 1.11) | use_lockfile = true |
| Lock object | Item in the table | <key>.tflock next to the state |
| State checksum (digest) | Stored in the table | Not needed; S3 strong consistency + versioning |
| IAM | S3 + DynamoDB permissions | S3 only |
| Cost | Small but non-zero | Free (a few requests) |
Step 1 – Bootstrap the state bucket
The bucket that stores state is the one piece of infrastructure Terraform cannot create for itself without a chicken-and-egg problem. Two clean solutions: create it with the CLI (below) or with a tiny separate Terraform project whose own state is committed once and then migrated into the bucket. Either way: versioning on, encryption with a customer-managed KMS key, all public access blocked, TLS enforced.
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=ca-central-1
BUCKET="tfstate-${ACCOUNT_ID}-${REGION}"
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION"
--create-bucket-configuration LocationConstraint="$REGION"
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
# Customer-managed key so you can audit and restrict decryption
KEY_ID=$(aws kms create-key --description "Terraform state" --query KeyMetadata.KeyId --output text)
aws kms create-alias --alias-name alias/terraform-state --target-key-id "$KEY_ID"
aws s3api put-bucket-encryption --bucket "$BUCKET" --server-side-encryption-configuration "{
"Rules": [{ "ApplyServerSideEncryptionByDefault": { "SSEAlgorithm": "aws:kms", "KMSMasterKeyID": "$KEY_ID" }, "BucketKeyEnabled": true }]
}"
# Enforce TLS and deny deletion of the bucket itself
cat > policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "DenyInsecureTransport", "Effect": "Deny", "Principal": "*",
"Action": "s3:*", "Resource": ["arn:aws:s3:::$BUCKET", "arn:aws:s3:::$BUCKET/*"],
"Condition": { "Bool": { "aws:SecureTransport": "false" } } },
{ "Sid": "DenyBucketDeletion", "Effect": "Deny", "Principal": "*",
"Action": "s3:DeleteBucket", "Resource": "arn:aws:s3:::$BUCKET" }
]
}
EOF
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://policy.json
# Keep old versions 90 days, then expire noncurrent ones
aws s3api put-bucket-lifecycle-configuration --bucket "$BUCKET" --lifecycle-configuration '{
"Rules": [{ "ID": "expire-old-state-versions", "Status": "Enabled", "Filter": {},
"NoncurrentVersionExpiration": { "NoncurrentDays": 90 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 } }]
}'Step 2 – IAM policy for state users
Whoever runs Terraform (developers, the CI role) needs exactly this on the bucket and key. Scope the object permissions to the prefix of the project to prevent one team from reading another team’s state, which contains secrets in clear text.
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::tfstate-<YOUR_ACCOUNT_ID>-ca-central-1",
"Condition": { "StringLike": { "s3:prefix": ["shop/*"] } } },
{ "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::tfstate-<YOUR_ACCOUNT_ID>-ca-central-1/shop/*" },
{ "Effect": "Allow", "Action": ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey"],
"Resource": "arn:aws:kms:ca-central-1:<YOUR_ACCOUNT_ID>:key/<KEY_ID>" }
]
}s3:DeleteObject is required for the lock file; state itself is never deleted by Terraform, only overwritten (versioning keeps history).
Step 3 – Configure the backend and migrate local state
# backend.tf
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "tfstate-<YOUR_ACCOUNT_ID>-ca-central-1"
key = "shop/dev/terraform.tfstate"
region = "ca-central-1"
encrypt = true
kms_key_id = "alias/terraform-state"
use_lockfile = true
}
}terraform init -migrate-state
# Do you want to copy existing state to the new backend? yes
aws s3 ls "s3://tfstate-${ACCOUNT_ID}-${REGION}/shop/dev/"
# terraform.tfstate
# Watch the lock appear during a long plan
terraform plan &
sleep 3 && aws s3 ls "s3://tfstate-${ACCOUNT_ID}-${REGION}/shop/dev/"
# terraform.tfstate
# terraform.tfstate.tflock <- exists only while the operation runs
rm -f terraform.tfstate terraform.tfstate.backup # local copies are now staleStep 4 – Migrate a project that uses DynamoDB locking
Existing projects keep working, but Terraform 1.11+ prints a deprecation warning for dynamodb_table. Migrate in two safe steps so that both mechanisms briefly overlap.
# Phase 1: both locks (run for one release cycle; every apply must succeed)
backend "s3" {
bucket = "tfstate-<YOUR_ACCOUNT_ID>-ca-central-1"
key = "shop/prod/terraform.tfstate"
region = "ca-central-1"
encrypt = true
dynamodb_table = "terraform-locks"
use_lockfile = true
}terraform init -reconfigure # backend settings changed, state location did not
terraform plan # verify: no changes, lock acquired in both places
# Phase 2: remove dynamodb_table from backend.tf, then
terraform init -reconfigure
terraform plan
# When every workspace/project has moved, delete the table and its IAM permissions
aws dynamodb delete-table --table-name terraform-locksIf you still have colleagues or pipelines on Terraform < 1.10, keep Phase 1 until they upgrade: older versions ignore use_lockfile and would otherwise run unlocked. Pin the version with required_version and a .terraform-version file (tfenv/tenv).
Step 5 – One state per environment and per blast radius
A single state for everything means a mistake in a dev change can touch production, and every plan takes minutes. Split by environment and by lifecycle. The key path encodes the split; the code is shared through modules.
s3://tfstate-<YOUR_ACCOUNT_ID>-ca-central-1/
├── shop/network/dev/terraform.tfstate # VPC, subnets – changes rarely
├── shop/network/prod/terraform.tfstate
├── shop/platform/dev/terraform.tfstate # EKS/ECS cluster, RDS
├── shop/platform/prod/terraform.tfstate
├── shop/app/dev/terraform.tfstate # services, deployed several times a day
└── shop/app/prod/terraform.tfstateBecause the backend block cannot contain variables, use a partial configuration: keep the shared settings in code and pass the key per environment.
# backend.tf – no key here
terraform {
backend "s3" {
bucket = "tfstate-<YOUR_ACCOUNT_ID>-ca-central-1"
region = "ca-central-1"
encrypt = true
kms_key_id = "alias/terraform-state"
use_lockfile = true
}
}# envs/dev.s3.tfbackend
# key = "shop/app/dev/terraform.tfstate"
terraform init -backend-config=envs/dev.s3.tfbackend
terraform apply -var-file=envs/dev.tfvars
# Switching environment = re-init with another backend file
terraform init -reconfigure -backend-config=envs/prod.s3.tfbackendStep 6 – Share outputs between stacks
The app stack needs the VPC ID from the network stack. Read it with the terraform_remote_state data source (read-only access to that state) or, for stricter isolation, publish outputs to SSM Parameter Store and read the parameters.
# In shop/app
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "tfstate-<YOUR_ACCOUNT_ID>-ca-central-1"
key = "shop/network/${var.environment}/terraform.tfstate"
region = "ca-central-1"
}
}
resource "aws_security_group" "app" {
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
}
# Alternative without state access: the network stack writes
# aws_ssm_parameter "/shop/${var.environment}/vpc_id", the app stack reads
data "aws_ssm_parameter" "vpc_id" {
name = "/shop/${var.environment}/vpc_id"
}Step 7 – CI/CD with GitHub Actions and OIDC
Pipelines must never hold long-lived keys. Use an IAM role assumed through OIDC (full setup in GitHub Actions to AWS with OIDC) with the state policy from Step 2 plus the permissions to manage the actual resources. Plan on pull requests, apply on main, one job at a time per environment.
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request: { paths: ["infra/**"] }
push: { branches: [main], paths: ["infra/**"] }
permissions: { id-token: write, contents: read, pull-requests: write }
concurrency: { group: terraform-${{ github.ref }}, cancel-in-progress: false }
jobs:
terraform:
runs-on: ubuntu-latest
defaults: { run: { working-directory: infra } }
env: { TF_IN_AUTOMATION: "true", ENVIRONMENT: dev }
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: "1.12.2" }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_TERRAFORM_ROLE_ARN }}
aws-region: ca-central-1
- run: terraform fmt -check -recursive
- run: terraform init -input=false -backend-config=envs/$ENVIRONMENT.s3.tfbackend
- run: terraform validate
- run: terraform plan -input=false -lock-timeout=5m -var-file=envs/$ENVIRONMENT.tfvars -out=tfplan
- if: github.event_name == 'push'
run: terraform apply -input=false -lock-timeout=5m tfplan-lock-timeout makes a job wait for another run’s lock instead of failing immediately, and the concurrency group prevents two applies on the same branch. Applying a saved plan file guarantees that what was reviewed is what gets applied.
Recovery procedures
A lock is stuck
A crashed process or a cancelled CI job can leave the .tflock object behind. Confirm nobody is running Terraform, then release it with the ID shown in the error.
# Error message contains: ID: 7f1c2e3a-... Who: ci@runner-42 Created: 2026-09-10 14:02:11
aws s3 cp "s3://$BUCKET/shop/app/dev/terraform.tfstate.tflock" - # inspect holder and time
terraform force-unlock 7f1c2e3a-... # preferred
# Last resort if force-unlock cannot run (e.g. broken init):
aws s3 rm "s3://$BUCKET/shop/app/dev/terraform.tfstate.tflock"State was corrupted or a bad apply went through
# List versions of the state object
aws s3api list-object-versions --bucket "$BUCKET" --prefix shop/app/dev/terraform.tfstate
--query 'Versions[].{id:VersionId,date:LastModified,size:Size}' --output table
# Download a previous version and inspect it
aws s3api get-object --bucket "$BUCKET" --key shop/app/dev/terraform.tfstate
--version-id <VERSION_ID> previous.tfstate
terraform show previous.tfstate | head -50
# Restore it as the current state (creates a new version; nothing is lost)
terraform state push previous.tfstate
terraform plan # confirm the plan matches expectations before any applyResources exist but are missing from state
# import.tf – declarative import (Terraform 1.5+), removable after apply
import {
to = aws_s3_bucket.assets
id = "shop-assets-prod"
}terraform plan -generate-config-out=generated.tf # writes the resource block for you
terraform applyChecklist
- Bucket: versioning, KMS encryption, public access blocked, TLS-only policy, lifecycle for old versions, deletion denied.
- Backend:
encrypt = true,use_lockfile = true, nodynamodb_table, key path per environment and stack. - IAM: prefix-scoped state access, separate roles for plan (read) and apply (write) where possible.
- CI: OIDC role, saved plan applied,
-lock-timeout, concurrency group, pinned Terraform version. - Runbooks: force-unlock, version restore, import; test them once in dev before you need them in prod.
Key takeaways
use_lockfile = truegives you locking with nothing but S3; DynamoDB is legacy.- Migrate with both locks enabled first, then drop DynamoDB once every runner is on 1.10+.
- Split state by environment and lifecycle; share values through remote state outputs or SSM.
- Versioning is your backup: any previous state can be restored with
terraform state push.
Related: Remote state on S3 (introduction), Workspaces, Modules. Official docs: S3 backend, State.
Retour parcours Terraform — hub de la série et leçons sœurs.


