Two engineers run terraform apply on the same project at the same time. Both read the same state, both make changes, and the second write silently overwrites the first. Terraform now believes resources exist that it never created, or forgets ones that it did. State is the part of Terraform that beginners ignore until it breaks.
This is part 3 of Terraform from Zero. You will learn what state contains, move it from your laptop to an S3 bucket with locking, and use the commands and blocks that let you change state safely.
What state is for
Your configuration describes what should exist. The cloud API shows what does exist. State is the map between them: for every resource address such as aws_s3_bucket.notes, Terraform records the real ID, every attribute it knows and the dependencies between resources.
Terraform needs that map to:
- Know which real bucket
aws_s3_bucket.notesrefers to, since many resources have generated names. - Work out what to update or delete when you change or remove configuration.
- Plan quickly without querying every attribute of every resource from scratch.
By default the state is a JSON file called terraform.tfstate next to your code. Look at it once with terraform show to understand what Terraform tracks, but never edit it by hand.
Why a local state file breaks teams
A local file has three problems as soon as more than one person or a CI pipeline is involved:
- No sharing. Your colleague's laptop has a different, older copy, or none at all.
- No locking. Nothing stops two applies from running at once.
- No protection. State can hold passwords and keys in plain text, and a laptop is easy to lose.
A remote backend solves all three: one shared copy, a lock during writes, and storage you can encrypt and back up.
Create a bucket for state
The state bucket is the one piece of infrastructure you create outside the project it serves, usually in a small separate "bootstrap" configuration. Its requirements are simple:
resource "aws_s3_bucket" "state" {
bucket = "acme-terraform-state-eu-west-2"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}Versioning is your undo button: if state is ever corrupted, you can restore the previous version of the object. prevent_destroy makes Terraform refuse any plan that would delete the bucket. S3 encrypts new objects at rest by default; add a KMS key if your organisation requires customer-managed keys.
Configure the S3 backend with native locking
Add a backend block to the project from parts 1 and 2:
terraform {
required_version = ">= 1.11"
backend "s3" {
bucket = "acme-terraform-state-eu-west-2"
key = "field-notes/dev/terraform.tfstate"
region = "eu-west-2"
encrypt = true
use_lockfile = true
}
}use_lockfile = true turns on S3 native locking. During any operation that writes state, Terraform creates a lock object next to the state with a .tflock suffix and removes it when it finishes. A second run that finds the lock waits or fails instead of overwriting your work.
This feature arrived as experimental in Terraform 1.10 and became generally available in 1.11. Before that, locking on S3 needed a separate DynamoDB table. The S3 backend documentation now marks DynamoDB-based locking as deprecated and due for removal in a future minor version, so new projects should use use_lockfile.
The identity that runs Terraform needs s3:ListBucket on the bucket, s3:GetObject and s3:PutObject on the state key, and s3:GetObject, s3:PutObject and s3:DeleteObject on the .tflock key.
Migrate existing local state
Backend blocks cannot use variables, because Terraform reads them before anything else. Once the block is in place, run:
terraform init -migrate-stateTerraform detects the local state, asks whether to copy it to the new backend and does so. Run terraform plan afterwards: it should report no changes. Then delete the local terraform.tfstate and its backup, since they are now stale copies.
If a run crashes and leaves a lock behind, Terraform prints the lock ID. After confirming that nothing else is running, release it with terraform force-unlock <LOCK_ID>.
Bring existing resources under Terraform with import blocks
Most teams start with resources that someone created by hand. Since Terraform 1.5 you can import them declaratively:
import {
to = aws_s3_bucket.legacy_logs
id = "acme-legacy-logs"
}Then let Terraform write the matching resource block for you:
terraform plan -generate-config-out=generated.tfReview generated.tf, tidy it into your normal files and apply. Because the import is a block in code, it goes through code review and CI like any other change. Remove the import block once the apply succeeds.
Rename and remove without destroying
Renaming a resource in code looks to Terraform like "delete the old one, create a new one", which for a bucket or database is a disaster. Tell Terraform it is the same object with a moved block:
moved {
from = aws_s3_bucket.notes
to = aws_s3_bucket.field_notes
}The plan now shows the address change and no replacement. Moved blocks also work when you move resources into a module, which you will do in part 4.
To stop managing a resource without deleting it, use a removed block (Terraform 1.7 and later):
removed {
from = aws_s3_bucket.legacy_logs
lifecycle {
destroy = false
}
}The older CLI commands terraform state mv and terraform state rm still exist, but blocks are reviewable and repeatable, so prefer them.
Read state without editing it
These commands are safe and worth knowing:
terraform state list # every tracked address
terraform state show aws_s3_bucket.notes # attributes of one resource
terraform show # human-readable view of everythingKey takeaways
- State maps your configuration to real resources; Terraform cannot plan safely without it.
- Local state has no sharing, locking or protection, so move it to a remote backend early.
- On AWS, use the S3 backend with
use_lockfile = true(Terraform 1.11+), a versioned bucket andencrypt = true; DynamoDB locking is deprecated. - Use
import,movedandremovedblocks to change state through code review instead of manual commands. - Never edit state by hand, and keep the state bucket protected with
prevent_destroy.
Next in the series: part 4 packages this configuration into a reusable module.

Ansible for Beginners: Automate an Ubuntu Server with Your First Playbook4 min read
Terraform and Ansible Together: Provision with One, Configure with the Other4 min read
Vercel Fluid Compute Explained: Concurrency, Active CPU Pricing and waitUntil4 min read