Terraform State Explained: Backends, Locking, and Surgery
Terraform State Explained: Backends, Locking, and Surgery
Terraform's state file is the piece everyone ignores until two engineers apply at once and corrupt each other's infrastructure. This is the deep guide: what state is, where it should live, and how to manipulate it without disasters.
Table of Contents
- What state actually is
- Remote state with locking
- The state surgery commands
- Refactoring blocks (Terraform ≥1.1/1.7)
- Workspaces vs. directories
- Getting data out for automation
What state actually is
State is Terraform's binding map: it records which real-world object corresponds to each resource instance in your config. Before every operation, Terraform refreshes state against real infrastructure, then diffs it against your config to build the plan. No state → Terraform can't tell your aws_instance.web exists, and would try to create a second one.
By default it's a local JSON file (terraform.tfstate + a .backup). Fine for solo experiments; a liability for teams.
Remote state with locking
Move state to a backend that supports locking — the mechanism that makes concurrent applies impossible:
terraform {
backend "s3" {
bucket = "my-tf-state"
key = "prod/web/terraform.tfstate"
region = "us-east-1"
use_lockfile = true # native S3 locking (Terraform ≥1.10)
encrypt = true
}
}
terraform init -migrate-state # moves local state to S3 once
On Terraform versions before native S3 lockfiles, add a DynamoDB table with dynamodb_table = "tf-locks". HCP Terraform works too — state, locking, and run history managed for you.
Two non-negotiables: never commit state to git (it can contain plaintext secrets and isn't lockable there) and never edit the file by hand.
The state surgery commands
terraform state list # everything tracked
terraform state show aws_s3_bucket.logs # full attributes of one resource
terraform state mv aws_s3_bucket.old \
aws_s3_bucket.new # rename without recreate
terraform state mv aws_vpc.main \
module.network.aws_vpc.this # move into a module
terraform state rm aws_instance.legacy # forget it (infra survives)
terraform import aws_instance.web i-0abc123 # adopt existing infra
state rm + import is the pair for adoption: "forget" a resource Terraform shouldn't manage anymore, or bind existing infrastructure into Terraform's care. Remember the one-to-one rule — every real object maps to exactly one resource instance.
Refactoring blocks (Terraform ≥1.1/1.7)
Renames can also be declared in code — version-controlled, reviewable, applied automatically:
moved {
from = aws_instance.old_name
to = aws_instance.new_name
}
removed {
from = aws_instance.decom
lifecycle { destroy = false } # drop from state, keep the box
}
Prefer moved/removed blocks over state mv/rm on shared configs — they're auditable and apply consistently for every teammate.
Workspaces vs. directories
terraform workspace new staging keeps one config with multiple state files. It's tempting for env parity, but hides which environment a change targets. Most teams outgrow workspaces for separate state files via separate backend configs — envs/staging, envs/prod directories with shared modules give better blast-radius control.
Getting data out for automation
terraform output -json # all outputs as JSON
terraform show -json plan.out # inspect a saved plan
terraform plan -out=plan.out # save for exact apply later
These emit stable JSON meant for tooling — pipe them to jq in CI to gate deployments on what a plan actually contains.
State done right is invisible: remote, locked, encrypted, never hand-edited. Set it up on day one — retrofitting is when teams lose things.
Related Articles
- Terraform 101: Managing Cloud Infrastructure as Code
- Terraform Modules: Building Reusable Infrastructure
Last Updated: October 2026 Author: CloudOpsGuide Team Difficulty: Advanced Estimated Reading Time: 12 minutes