Terraform Remote State & Locking
Move state off your laptop into an encrypted, versioned, locked backend — and prove the lock works by breaking it deliberately.
- Time
- 45 min
- Level
- Intermediate
- Objectives
- 4 objectives
- Cost
- Low cost
Where this fits in the platform
Already built
This lab adds
- State in S3 with locking — safe for more than one person
Before you start
You will need
- Terraform >= 1.6
- AWS CLI v2, configured
You do not need these already — the lab environment below provides them.
You will be able to
- Migrate local state to an S3 backend without recreating resources
- Prove a concurrent apply is blocked rather than corrupting state
- Recover from a stale lock safely
Cost — Low cost
— an S3 bucket and a PAY_PER_REQUEST DynamoDB table. A few applies a week costs effectively nothing.
Nothing to pay in the browser. Open the terminal runs this against a simulated cloud — the same API calls and the same commands, with no account and no bill. The figure above applies only if you build it in your own.
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.
Hands-on environment
Run this lab in a real terminal, free and in your browser. The environment is temporary and yours alone — break it as much as you like.
Open the terminalOpens in Killercoda, in a new tab — keep this page open for the steps.
Run it on AWS
This lab builds real cloud infrastructure, so it needs your own AWS account. Follow the cost and cleanup notes above — the resources are yours, and so is the bill.
Anything you tick here is your own record. EgyKode cannot see inside that terminal, so the success criteria stay self-assessed even when the environment checks your work for you.
The backend has a chicken-and-egg problem
Step 1 of 4
What you are proving: You can create a state bucket that is versioned, encrypted and closed to the public, and explain why it cannot be created by the backend that uses it
Marking this settles success criterion 2.
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:
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_REQUESTEach 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.
LockIDas the hash key is what Terraform expects.
What you are proving: You can migrate existing state into S3 without any resource being destroyed and recreated
Marking this settles success criteria 1 and 4.
terraform {
backend "s3" {
bucket = "tfstate-REPLACE-ME"
key = "demo/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks"
encrypt = true
}
}terraform init -migrate-stateTerraform 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:
aws s3 ls "s3://$BUCKET/demo/"
ls terraform.tfstate 2>/dev/null || echo "no local state — correct"
terraform plan # must report: No changesThat 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.
What you are proving: You can prove a second concurrent apply fails on the lock rather than corrupting state
Marking this settles success criterion 3.
In one terminal:
terraform apply # leave it sitting at the confirmation promptIn a second terminal, in the same directory:
terraform planError: Error acquiring the state lock
Lock Info:
ID: 7a1f...
Operation: OperationTypeApply
Who: waleed@laptop
Created: 2026-08-10 14:02:11That 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.
What you are proving: You can recognise a stale lock and release it deliberately rather than by force
This step settles no success criterion on its own.
A crashed apply — closed laptop, dropped connection — leaves the lock held.
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.
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.
Destructive — This removes real resources. Check which environment you are in first.
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.
Success criteria
0 of 4
The concept behind it
Next up
Lab 22 of 59 on the project path