CloudOpsGuide
terraform

Terraform State Explained: Backends, Locking, and Surgery

Advanced
12 minutes
October 2026
CloudOpsGuide Team

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

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


Last Updated: October 2026 Author: CloudOpsGuide Team Difficulty: Advanced Estimated Reading Time: 12 minutes