Terraform state
How Terraform remembers — and keeping that memory safe.
Part 1: The problem
For six months, one engineer — Maya — ran all of QuickCart’s Terraform from her laptop. The code was in git, so when Leo joined, he had all of it.
He ran terraform apply. Terraform planned to create a second load balancer and a second database. Nothing in the code had changed, and nobody had asked for duplicates.
Two weeks later, after a quick fix, they both ran apply within the same minute. Afterwards, Terraform no longer knew about a database Maya had just built. It was still running and still costing money, but nobody’s code was managing it any more.
Both problems have the same cause: a file called terraform.tfstate that lived only on Maya’s laptop.
This lesson is about what that file is, and the four things a team does to keep it safe.
Part 2: The idea
After an apply, Terraform writes terraform.tfstate: a record of every resource it created, linked to that resource’s real ID in AWS.
Your code says an ECR repository named quickcart-orders. Your AWS account may have thousands of things in it. State is how Terraform knows which one is yours, and whether it already exists.
Every plan compares three things
| Your code | State | AWS | What the plan does |
|---|---|---|---|
| has it | doesn’t | doesn’t | Create it |
| has it | has it | has it, matching | Nothing |
| has it | has it | someone changed it | Change it back to match the code |
| deleted it | has it | has it | Destroy it |
| has it | lost | has it | Create a duplicate — Leo’s problem |
That last row is why state matters so much. Without it, Terraform can’t tell that something already exists.
Why two people at once breaks it
What’s inside it
Every attribute of every resource: IDs, names, addresses, settings AWS filled in — and any secret that passed through Terraform, in plain text. So state has to be treated like a password file, because in part it is one.
The fix, in four parts
| Part | What it does | Which problem it solves |
|---|---|---|
| Remote — kept in S3 | Everyone reads and writes the same copy | Leo’s duplicates, and a lost laptop |
| Locking | Only one apply at a time; others are refused | Two applies erasing each other’s record |
| Versioning | Every past version of the file is kept | A damaged state can be rolled back |
| Encryption and no public access | Unreadable to anyone who shouldn’t see it | The secrets inside the state |
The backend block, every setting explained
You tell Terraform where state lives with a backend block, usually in a file called backend.tf:
terraform {
backend "s3" {
bucket = "quickcart-tfstate-111122223333"
key = "dev/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}| Setting | What it does | If you leave it out | When you’d change it |
|---|---|---|---|
backend "s3" | Store state in S3 instead of on your laptop | State stays in a local file, with all the problems above | Never, once a team shares the code |
bucket | Which bucket holds the file. Bucket names are unique across all of AWS, so adding your account number keeps yours unique | Required — terraform init stops with an error | Never after the first init; moving state is a deliberate migration |
key | The file’s path inside the bucket. Each environment gets its own, like dev/… and stg/… | Required — terraform init stops with an error | Once per environment |
region | Which region the bucket is in — not where your servers are | Terraform uses your default region and may not find the bucket | Only if the bucket is in another region |
encrypt | Encrypt the state file when it’s written to S3 | S3 still encrypts new objects by default today, but saying so makes it explicit and survives a bucket setting being changed | Never |
use_lockfile | While a command runs, keep a lock file next to the state in S3. Needs Terraform 1.10 or later | No locking: two people can apply at once | Never |
The chicken and the egg
The bucket that holds state can’t be created by the Terraform code that needs it — where would that state live? So you create it once, by hand, and never manage it with Terraform. It’s the one place where clicking in the console is correct.
Part 3: See it
First, the three-way comparison. Pick what happens, and see what the next plan does about it.
Your code
what you asked forState file
what Terraform remembersAWS
what really exists$ terraform plan No changes. Your infrastructure matches the configuration.
Now the race. Maya and Leo press apply at the same moment. Play it with locking off, then again with locking on, and watch the state file.
What to notice: with locking off, both applies finished successfully — no error, no warning. That’s what makes it dangerous. Nothing tells you anything went wrong until a later plan tries to create something that already exists.
Part 4: Do it
Pennies — one S3 bucket holding a few small files, and an empty ECR repository, which is free.
You’ll build a tiny Terraform project, move its state into S3, watch the lock refuse a second command, and bring back an old version of the state. About 30 minutes.
You need Terraform 1.10 or later and a working AWS login in your terminal. Set up your computer covers both. Check with:
terraform version| Part | What it means |
|---|---|
terraform version | print the version of Terraform installed |
Terraform v1.16.4
on linux_amd64Any version from 1.10 upwards works. Yours may be newer, and the second line names your system, such as darwin_arm64 on a Mac.
Make a small project with local state
If you already have a Terraform folder from Your first resource, you can use it and skip to step 2. Otherwise, make a new folder and move into it:
Terminalmkdir quickcart-tfPart What it means mkdirmake a directory quickcart-tfits name, inside the folder you’re in now Terminalcd quickcart-tfPart What it means cdchange directory — every later command runs in here quickcart-tfthe folder you just made In that folder, create
main.tf:main.tfterraform { required_version = ">= 1.10.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = "us-east-1" } resource "aws_ecr_repository" "orders" { name = "quickcart-orders" image_tag_mutability = "IMMUTABLE" force_delete = true tags = { Project = "quickcart" } }Setting What it does If you leave it out When you’d change it required_versionRefuse to run on a Terraform older than 1.10, which has no use_lockfileAn old Terraform would fail later with a confusing error about the backend When you start relying on a newer feature required_providers→awsWhich plugin talks to AWS: hashicorp/aws, any 6.x versionTerraform guesses the source and takes the newest version, which may one day bring breaking changes When you deliberately upgrade to a new major version provider "aws"→regionWhere the resources are created Terraform uses the region from your AWS login settings, which may not be the one you expect To build somewhere else aws_ecr_repository"orders"An ECR repository — a private store for container images. ordersis Terraform’s name for it;quickcart-ordersis AWS’s— — nameThe repository’s name in AWS Required Never after creation — changing it replaces the repository image_tag_mutabilityIMMUTABLEmeans a tag likev1can never be pointed at a different imageDefaults to MUTABLE: someone can push a different image under the same tagRarely. Storing images: ECR explains why force_deleteLet terraform destroydelete the repository even if it holds imagesDestroy fails while any image is inside Set to falsefor production repositoriestagsLabels, so you can find and cost everything belonging to QuickCart No labels; harder to find later Add owner, environment and cost-centre tags in real projects Set up the folder and build the repository:
Terminalterraform initPart What it means terraform initdownload the AWS provider and prepare this folder You should seeTerraform has been successfully initialized!Terminalterraform applyPart What it means terraform applywork out a plan, show it, and carry it out once you type yesIt shows the plan and asks you to confirm. Type
yes.You should seePlan: 1 to add, 0 to change, 0 to destroy. Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes aws_ecr_repository.orders: Creating... aws_ecr_repository.orders: Creation complete after 1s [id=quickcart-orders] Apply complete! Resources: 1 added, 0 changed, 0 destroyed.You now have a file called
terraform.tfstatein the folder. That’s Terraform’s memory, sitting on your laptop — Maya’s situation.Create the state bucket in the console
First, find your account number: open the account menu at the top right of the console and copy the 12-digit Account ID. In this course it’s shown as
111122223333; use yours instead.In the AWS console: S3General purpose bucketsCreate bucket
Field Enter Bucket type General purpose Bucket name quickcart-tfstate-followed by your account ID, likequickcart-tfstate-111122223333Object Ownership ACLs disabled — the default Block all public access On — the default. Leave it Bucket Versioning Enable Default encryption SSE-S3 — the default. Leave it Choose Create bucket.
You should seeThe bucket in the list. Open it, go to Properties, and check that Bucket Versioning says Enabled.
Versioning is the setting people skip, and regret. It’s the only way back if the state is ever damaged.
Add the backend block
In the same folder as
main.tf, createbackend.tf. Put your own bucket name in place of the highlighted account number:backend.tfterraform { backend "s3" { bucket = "quickcart-tfstate-111122223333" key = "dev/terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } }Every setting is explained in the idea, above.
Move the state across
Terminalterraform init -migrate-statePart What it means terraform initset the folder up again, now with the S3 backend -migrate-statecopy the state you already have into the new backend It asks before copying. Type
yes.It asks, then confirmsInitializing 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: yes Successfully configured the backend "s3"! Terraform will automatically use this backend unless the backend configuration changes.Check it worked
In the AWS console: S3General purpose bucketsyour bucketdev/
You should seeA file called
terraform.tfstate.Now ask Terraform to compare everything again, reading state from S3 this time:
Terminalterraform planPart What it means terraform plancompare code, state and AWS, and show what would change — without changing anything You should seeNo changes. Your infrastructure matches the configuration.Same result as before. Only the state’s location changed.
Watch the lock refuse a second command
To see the lock you need a command that stops half-way and holds it.
applydoes that while it waits for you to typeyes— but only when there’s a change to approve. So first make a small change: add a second tag,Team, at the end ofmain.tf. The whole file now reads:main.tfterraform { required_version = ">= 1.10.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = "us-east-1" } resource "aws_ecr_repository" "orders" { name = "quickcart-orders" image_tag_mutability = "IMMUTABLE" force_delete = true tags = { Project = "quickcart" Team = "platform" } }Now open two terminal windows, both in the
quickcart-tffolder.Window 1 — start an apply. When it asks
Enter a value:, don’t answer yet:Terminalterraform applyPart What it means terraform applytakes the lock, plans the tag change, then waits for you — still holding the lock Window 2 — while window 1 is waiting:
Terminalterraform planPart What it means terraform planalso needs the lock, even though it changes nothing, so it reads a state nobody is halfway through writing Window 2 is refusedError: Error acquiring the state lock Lock Info: ID: 6f1c2d9e-3b7a-4c55-9e2f-8a0d4b1c7e21 Path: quickcart-tfstate-111122223333/dev/terraform.tfstate Operation: OperationTypeApply Who: maya@maya-laptop Version: 1.16.4 Created: 2026-09-25 10:22:11.482913 +0000 UTC Info: Terraform acquires a state lock to protect the state from being written by multiple users at the same time. Please resolve the issue above and try again. For most commands, you can disable locking with the "-lock=false" flag, but this is not recommended.Your ID, name and times will differ, and there’s an extra
Error messageline from S3 explaining the refusal. That refusal is the whole point. It tells you who holds the lock and since when.Now go back to window 1 and type
yes. The tag is added and the lock is released. Runterraform planin window 2 again, and this time it works.Bring back an old version
Because of the tag change, the bucket now holds more than one version of the state.
In the AWS console: S3your bucketdev/terraform.tfstateVersions
You should seeTwo or more versions, each with a time. The newest is at the top, marked Current version.
Tick the older version, choose Download, and move the file into your project folder, renamed to
recovered.json. Then replace the state with it:Terminalterraform state push recovered.jsonPart What it means terraform state pushreplace the state in S3 with a file you give it recovered.jsonthe older version you downloaded Straight afterwards, see how the recovered state compares with reality:
Terminalterraform planPart What it means terraform planrefresh the state from AWS, then compare it with the code You should seeNo changes. Your infrastructure matches the configuration.The old version didn’t know about the
Teamtag, butplanreads the real repository from AWS first and finds the tag already there. The recovery was safe because the old state still pointed at the same real resource.Clean-up step: Clean up
Delete the repository Terraform built:
Terminalterraform destroyPart What it means terraform destroyplan the removal of everything in the state, and carry it out once you type yesYou should seeDestroy complete! Resources: 1 destroyed.Keep the bucket if you’re carrying on with the course. It costs pennies, and later lessons and the pipeline project use it.
If you’re not carrying on, empty it and delete it: S3 → your bucket → Empty, type
permanently delete, then Delete the bucket itself. Emptying a versioned bucket removes every old version too.
If a lock ever gets stuck
A laptop goes to sleep mid-apply, or a terminal is closed. The lock is never released, and everyone is refused. First, check that nobody is really applying — the error tells you who holds the lock. Then remove it using the ID from the error:
terraform force-unlock LOCK_ID| Part | What it means |
|---|---|
terraform force-unlock | remove a lock by hand |
LOCK_ID | replace this with the ID line from the lock error |
Do you really want to force-unlock?
Terraform will remove the lock on the remote state.
This will allow local Terraform commands to modify this state, even though it
may still be in use. Only 'yes' will be accepted to confirm.
Enter a value: yes
Terraform state has been successfully unlocked!Part 5: Check yourself
Leo clones the repository and runs apply. Terraform plans to create duplicates of everything. Why?
What is wrong with committing terraform.tfstate to git?
Two people apply at the same time without locking. What happens?
Your dev and stg environments keep state in the same bucket. What must be different between them?
Someone's apply crashed hours ago and now nobody can apply. What do you do first?
Part 6: Cheat sheet
| Command or setting | What it does |
|---|---|
terraform state list | List everything Terraform is tracking |
terraform state show aws_ecr_repository.orders | Every detail of one resource |
terraform init -migrate-state | Move state into a new backend |
terraform state pull | Print the current state, to save a copy |
terraform state push FILE | Replace the state. Refused if the file is older, unless you add -force |
terraform force-unlock LOCK_ID | Clear a stuck lock, after checking nobody holds it |
key | One per environment: dev/…, stg/… |
use_lockfile = true | One command at a time. Terraform 1.10 or later |
| Bucket versioning | Turn it on before you need it |
.gitignore | *.tfstate and *.tfstate.* — never commit state |