Local state works perfectly until there are two of you. Then it stops working in a way that is hard to notice and expensive to fix: two people apply at once, and the second one's state file overwrites the first's, so Terraform now believes it did not create things that exist.
This lesson moves state into S3 and turns on locking. It also corrects the advice you will find almost everywhere else, which tells you to create a DynamoDB table that Terraform deprecated.
What local state cannot do
it is on ONE laptop nobody else can plan, and CI certainly cannot
it is not backed up lose the file, lose the mapping to real resources
it has no locking two applies at once corrupt it
it holds secrets in cleartext, on a laptop, next to a git repoLosing the state file is worth dwelling on, because it is not "start again". The infrastructure still exists and nothing knows it is yours — you either import every resource by hand or delete it all through the console.
The bootstrap problem
State goes in an S3 bucket. Something has to create the bucket. That something cannot store its state in the bucket it is about to create.
Two honest answers. Create it with the CLI, once, and never manage it with Terraform:
BUCKET=my-terraform-state-329580012644
aws s3api create-bucket --bucket "$BUCKET" \
--region us-west-2 --create-bucket-configuration LocationConstraint=us-west-2
# Versioning is the undo button for a corrupted or truncated state file.
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=trueOr write it in Terraform in its own directory, apply with local state, then migrate that directory into itself. It works and it is elegant, and for one bucket the CLI is less to explain. Either way it is a one-time act per account.
All four settings matter. Versioning is your recovery path. Encryption is table stakes for a file holding cleartext credentials. The public access block is because this is the single worst bucket in your account to get wrong.
The backend block
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "my-terraform-state-329580012644"
key = "pizza/terraform.tfstate"
region = "us-west-2"
encrypt = true
# ⚠️ THIS IS THE LINE THAT REPLACED THE DYNAMODB TABLE. See below.
use_lockfile = true
}
}key is the path within the bucket, and it is how one bucket holds many stacks. Give
it real structure — pizza/prod/terraform.tfstate,
pizza/staging/terraform.tfstate — because you will have more of these than you expect.
⚠️ The backend block cannot use variables. Not var.bucket, not a
local, not an interpolation. It is read before Terraform evaluates anything, so it must be literal.
That is why per-environment backends use partial configuration:
# backend.tf has only: backend "s3" {}
terraform init -backend-config=prod.s3.tfbackend⚠️ You do not need a DynamoDB table
Every S3 backend tutorial written before 2025 tells you to create one:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "pizza/terraform.tfstate"
region = "us-west-2"
# ⚠️ DEPRECATED. Do not copy this.
dynamodb_table = "terraform-locks"
}
}The history: S3 had no way to conditionally create an object, so Terraform could not implement a lock on S3 alone. DynamoDB's conditional writes could, so the backend borrowed one — and every tutorial since has carried the extra table, its IAM policy and its cost.
In 2024 S3 gained conditional writes. Terraform 1.10 added native locking via
use_lockfile, and 1.11 made it the default and deprecated dynamodb_table,
which now emits a warning and is slated for removal.
Native locking writes a .tflock object next to the state and relies on S3 refusing a
conditional put when it already exists. One bucket, one permission set, no second service.
use_lockfile = true <-- current. one bucket, nothing else.
dynamodb_table = "..." <-- deprecated. delete the table when you remove it.⚠️ This is why required_version = ">= 1.10" is load-bearing. An older Terraform
reading use_lockfile does not recognise the argument and proceeds with no
locking at all rather than failing. The version floor is what makes the lock real.
Migrating existing state
You have local state and you have just added a backend block:
terraform init -migrate-stateInitializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend
to the newly configured "s3" backend. No existing state was found in the
newly configured "s3" backend. Do you want to copy this state to the new
"s3" backend? Enter "yes" to copy and "no" to start with an empty state.
Enter a value: yesSay yes. Then confirm before deleting anything local:
terraform state list # should list everything it did before
terraform plan # should say "No changes""No changes" is the proof the migration worked. Only then remove
terraform.tfstate and its backup — and remember the backup contains the same secrets, so
delete it rather than leaving it around.
Locking, in practice
When someone else is applying:
Error: Error acquiring the state lock
Lock Info:
ID: 4d31c0a8-3b5c-...
Path: my-terraform-state/pizza/terraform.tfstate
Operation: OperationTypeApply
Who: folauk@laptop
Created: 2026-08-24 20:14:03 UTCThat is the system working. Wait.
Occasionally a lock is left behind — a crashed process, a cancelled CI job:
terraform force-unlock 4d31c0a8-3b5c-...⚠️ Only after you have confirmed nobody is actually applying. The lock exists to
prevent two concurrent writes; forcing it while a colleague's apply is running is how you get the
corruption it was there to stop. Check the Who and ask them.
Permissions
The minimum for a state bucket with native locking:
data "aws_iam_policy_document" "state_access" {
statement {
actions = ["s3:ListBucket"]
resources = ["arn:aws:s3:::my-terraform-state-329580012644"]
}
statement {
actions = [
"s3:GetObject",
"s3:PutObject",
# DeleteObject is for releasing the .tflock, not for deleting state.
"s3:DeleteObject",
]
resources = ["arn:aws:s3:::my-terraform-state-329580012644/pizza/*"]
}
}Scope the object permissions to the key prefix, not the whole bucket. One team's
pipeline having read access to every other team's state — passwords included — is a real and common
over-grant.
One bucket, many states
The question this raises: how many state files should there be?
Not one. A single state for an entire account means every plan refreshes every resource, every
apply locks everything, and one bad destroy reaches production. Split by blast radius
and by rate of change — networking changes rarely, applications change hourly:
network/prod/terraform.tfstate rarely changes, everything depends on it
pizza/prod/terraform.tfstate changes constantly
pizza/staging/terraform.tfstateThe seam between them is outputs, read with the terraform_remote_state data source
from lesson 7. Lesson 11 is about
where to draw those lines.
Next
Lesson 9 stops you writing three
near-identical subnet blocks — and explains the specific reason count will destroy and
recreate half your resources.