Skip to content
EgyKode
Guided lab

Terraform Remote State & Locking

45 minIntermediate

This creates billable resources. Run it in a dev environment and destroy it when you finish. Set a budget alarm first.

Success criteria

0 of 4

The scenario#

State is on your laptop. A colleague runs terraform apply from theirs, sees none of your resources, and creates a second copy of everything — or worse, destroys yours.

This is the lab that makes Terraform usable by more than one person.

1. The backend has a chicken-and-egg problem#

The bucket that holds state cannot itself be created by the configuration that stores state in it. So it is created first, on its own:

Terminal
BUCKET="tfstate-$(date +%s)"
 
aws s3api create-bucket --bucket "$BUCKET" --region us-east-1
aws s3api put-bucket-versioning --bucket "$BUCKET" \
  --versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket "$BUCKET" \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws s3api put-public-access-block --bucket "$BUCKET" \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
 
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

Each of those four bucket settings is there for a reason:

  • Versioning — a corrupted state file with no previous version is one of the few genuinely unrecoverable situations in Terraform.
  • Encryption — state routinely holds secrets in plain text.
  • Public access block — it should not need saying, and it does.
  • DynamoDB — the lock. LockID as the hash key is what Terraform expects.

2. Declare the backend and migrate#

hcl
terraform {
  backend "s3" {
    bucket         = "tfstate-REPLACE-ME"
    key            = "demo/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks"
    encrypt        = true
  }
}
Terminal
terraform init -migrate-state

Terraform notices the backend changed and offers to copy existing state up. Say yes. This is a metadata move — no AWS API calls that alter real infrastructure — so nothing is destroyed or recreated.

Confirm:

Terminal
aws s3 ls "s3://$BUCKET/demo/"
ls terraform.tfstate 2>/dev/null || echo "no local state — correct"
terraform plan          # must report: No changes

That No changes is the proof the migration was clean. If it wants to create everything, the state did not come across and you should stop.

3. Prove the lock works#

In one terminal:

Terminal
terraform apply           # leave it sitting at the confirmation prompt

In a second terminal, in the same directory:

Terminal
terraform plan
text
Error: Error acquiring the state lock
Lock Info:
  ID:        7a1f...
  Operation: OperationTypeApply
  Who:       waleed@laptop
  Created:   2026-08-10 14:02:11

That error is the feature. Without it, two applies write the same file and the result is a state that matches neither reality nor either engineer's intent.

4. When a lock goes stale#

A crashed apply — closed laptop, dropped connection — leaves the lock held.

Terminal
terraform force-unlock 7a1f...

Read the lock info before you do this. Who and Created tell you whether a colleague is mid-apply right now, in which case force-unlocking is how you create the corruption the lock existed to prevent.

When it goes wrong#

terraform init wants to create every resource again

State was not migrated. Re-run terraform init -migrate-state; if the local file is gone, terraform import each resource.

Error acquiring the state lock when nobody else is running

A previous run crashed. Read the lock info, confirm nobody is applying, then terraform force-unlock <id>.

AccessDenied writing state

The principal needs s3:PutObject on the key and dynamodb:PutItem/DeleteItem on the lock table.

The bucket will not delete

Versioning keeps every old object. Delete all versions, not just current objects — see the cleanup steps.


Clean up#

Run this even if you did not finish.

DestructiveThis removes real resources. Check which environment you are in first.

Terminal
terraform destroy -auto-approve
# Empty the state bucket before deleting it (versioning keeps old objects):
aws s3 rm s3://<state-bucket> --recursive   # current objects
# Versioning keeps old objects; remove every version before deleting the bucket.
# In the console: Empty bucket, which handles versions and delete markers.
aws s3 rb s3://<state-bucket> --force
aws dynamodb delete-table --table-name <lock-table>

Cost of this lab: Free tier — an S3 bucket and a PAY_PER_REQUEST DynamoDB table. A few applies a week costs effectively nothing.

The concept behind it

Ready to try it without help?Do the challenge