Terraform keeps track of what it has built in a state file. By default, that file sits on your own machine, which is fine for a first experiment and a problem for everything after it. Lose the laptop, and you lose the record of your infrastructure. Add a second engineer, or a CI pipeline, and now two copies of the truth can drift apart.
The fix is remote state. In my AWS infrastructure project, I store state in an S3 backend, and this article walks through the setup and the decisions behind it.
Why local state breaks down
Terraform compares your configuration against the state file to decide what to create, change, or destroy. If that file is wrong or missing, Terraform plans against a picture of the world that isn’t real, and it may try to recreate resources that already exist.
Local state also holds sensitive values in plain text, such as generated passwords and resource attributes. Keeping it in a Git repository is a common mistake that exposes them.
Create the bucket first
The backend bucket has to exist before Terraform can use it, so you create it outside the configuration that depends on it. A few CLI commands are enough. Replace the bucket name with your own, since S3 names are globally unique.
aws s3api create-bucket \
--bucket my-terraform-state-bucket \
--region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
aws s3api put-bucket-versioning \
--bucket my-terraform-state-bucket \
--versioning-configuration Status=Enabled
aws s3api put-public-access-block \
--bucket my-terraform-state-bucket \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=trueVersioning matters most here. Every change to state is kept as a version, so a bad apply or a corrupted file can be rolled back. Blocking public access keeps the state, and the secrets inside it, private.
Point Terraform at the bucket
Add a backend block to your configuration.
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "envs/dev/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}Each setting does a job:
keyis the path of the state file inside the bucket. Using a path per environment, such asenvs/devandenvs/prod, keeps states separate.encryptturns on server-side encryption for the state object.use_lockfileturns on state locking, so two people runningterraform applyat the same time can’t write over each other. This option arrived in Terraform 1.10. Older guides use a DynamoDB table for locking, which you no longer need on current versions.
Callout, tone: warning, text: The backend block can’t use variables. Bucket, key, and region must be written as literal values, or supplied with -backend-config at init time.
Initialise and migrate
If you already have local state, run init with the migrate flag. Terraform asks to copy your existing state into the bucket.
terraform init -migrate-stateAfter it finishes, confirm the state file exists in the bucket, then delete the local terraform.tfstate and its backup so there’s only one source of truth.
Give access with least privilege
The identity running Terraform, whether a person or a pipeline, needs only a few permissions on the bucket and the state path.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-terraform-state-bucket"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-terraform-state-bucket/envs/*"
}
]
}Scoping the object permissions to the envs/ path means the same bucket can serve several projects without every identity seeing every state file.
What I’d check before relying on it
- Versioning is on, and you’ve confirmed an old version can be restored.
- Public access is blocked at the bucket level.
- State is encrypted.
- Only the identities that need it can read or write the state path.
- The local state files are gone and
*.tfstateis in.gitignore.