Engineering Notes
Home/DevOps & Cloud/Terraform/State and Remote Backends
DevOps & Cloud · Terraform

State and Remote Backends

State maps your code to real resource IDs, and it's both essential and sensitive. Learn refresh, drift and state surgery, then move state to a remote backend with locking and watch two concurrent applies get safely serialized.

☁️ DevOps & Cloud● Intermediate⏱ 8 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Why Terraform needs state and what it contains
  • Refresh, plan and drift; state is sensitive
  • Remote backends, locking and protecting the state bucket
  • Splitting state to limit blast radius, and sharing data between units

📚 Before you start

Part 1What State Is

1Why Terraform Needs State

Terraform's configuration says what should exist. To compute a plan it must also know what it already manages and how configuration addresses map to real objects:

text
aws_s3_bucket.uploads   →   bucket "petpal-prod-uploads"  (arn, region, versioning…)
aws_instance.web[0]     →   i-0a1b2c3d4e5f67890
module.vpc.aws_vpc.this →   vpc-0123456789abcdef0

That mapping lives in the state (terraform.tfstate, JSON). State also caches attribute values (so plans don't need to read every attribute of every resource), records dependencies for correct destroy order, and stores outputs other configurations can read.

json
{
  "version": 4,
  "serial": 42,
  "lineage": "6f1c…",
  "resources": [{
    "mode": "managed", "type": "aws_s3_bucket", "name": "uploads",
    "instances": [{ "attributes": { "id": "petpal-prod-uploads", "arn": "arn:aws:s3:::petpal-prod-uploads", "...": "..." } }]
  }]
}
💡 Example: one state entry
json
{
  "type": "aws_db_instance",
  "name": "main",
  "instances": [{
    "attributes": {
      "id": "petpal-prod",
      "endpoint": "petpal-prod.c9x.eu-west-1.rds.amazonaws.com:5432",
      "password": "s3cr3t-in-plain-text!"
    }
  }]
}

The address aws_db_instance.main maps to the real ID petpal-prod. Note the password: state contains secrets, so encrypt it and restrict access.

2Refresh, Plan, Drift

On plan, Terraform refreshes: for each resource in state, it asks the provider to read the real object. Differences between state and reality are drift (someone changed something outside Terraform). The plan then compares config with the refreshed state.

  • terraform plan -refresh-only shows drift without proposing config changes; apply -refresh-only accepts reality into state.
  • A resource deleted manually shows up as "will be created" again.
  • A resource created manually is invisible to Terraform until imported (02-terraform_basics/ §5).

3State Is Sensitive

State contains every attribute of managed resources — including secrets: database master passwords set via Terraform, generated private keys (tls_private_key), access keys, connection strings. Even attributes marked sensitive = true are hidden only in CLI output, not in the state file.

Therefore:

  • never commit state to Git;
  • store it encrypted with tight access control (03-… Part 2);
  • prefer resources that keep secrets out of state (e.g. RDS manage_master_user_password = true stores the password in Secrets Manager instead).

4Local State Doesn't Work for Teams

With the default local backend (terraform.tfstate on a laptop):

  • two people have two different states → each one's plan is wrong;
  • simultaneous applies corrupt infrastructure;
  • a lost laptop = Terraform no longer knows what it owns;
  • CI can't run plans.

The fix is a remote backend with locking — Part 2.

5Inspecting and Repairing State

bash
terraform state list                         # all managed addresses
terraform state show aws_s3_bucket.uploads   # attributes of one
terraform state mv  <from> <to>              # rename (prefer moved {} blocks)
terraform state rm  <addr>                   # forget (prefer removed {} blocks)
terraform import    <addr> <id>              # adopt (prefer import {} blocks)
terraform force-unlock <lock-id>             # only if a lock is truly stale
terraform state pull > backup.tfstate        # backup before surgery

Config-driven blocks (import, moved, removed) are reviewed in pull requests; CLI state surgery is not — use it only for emergencies, after a backup.

Under the HoodSerial and Lineage▾

Every state has a lineage (a UUID created with the state) and a serial (incremented on every write). When Terraform writes state, the backend can reject writes from an older serial or a different lineage — protecting against a stale copy (e.g. an old local file) overwriting newer state. The state also records each resource instance's dependencies, so even after you delete a resource from the configuration, Terraform knows the order in which to destroy it relative to others. Because state is the source of Terraform's knowledge, recovering from a corrupted or deleted state means re-importing every resource — which is why backends with versioning (S3 versioning, Terraform Cloud history) are essential.

Terraform compares configuration against state refreshed from real infrastructure; state maps resource addresses to real IDs and holds sensitive attributes, so it lives in an encrypted, versioned, locked remote backend
Terraform compares configuration against state refreshed from real infrastructure; state maps resource addresses to real IDs and holds sensitive attributes, so it lives in an encrypted, versioned, locked remote backend

⚠️ Common Pitfalls (Part 1)

  1. State in Git — leaks secrets, merge conflicts.
  2. Manual state edits without a backup.
  3. Ignoring drift until an apply reverts someone's emergency fix.
  4. Assuming sensitive = true encrypts state — it doesn't.

📋 Quick Reference (Part 1)

text
state = address → real object mapping + cached attributes + dependencies + outputs
plan = refresh (read reality) + diff config vs refreshed state
drift: plan -refresh-only · state contains secrets → encrypt, restrict, never commit
prefer import / moved / removed blocks over state CLI surgery; backup first
Part 2Remote Backends, Locking and Splitting State

1Remote Backends

A backend decides where state is stored and how it's locked. Common choices:

BackendStorageLocking
s3S3 object (versioned, encrypted)S3 native lockfile (use_lockfile = true, Terraform ≥ 1.10) or a DynamoDB table (legacy)
gcsGoogle Cloud Storagebuilt-in
azurermAzure Blob Storageblob lease
remote / cloudHCP Terraform / Terraform Enterprisebuilt-in, plus runs, policies, history
pg, http, othersvariousvaries
hcl
terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"           # versioning + encryption + public access block ON
    key          = "prod/network/terraform.tfstate"  # one key per unit/environment
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true                               # S3-native state locking
  }
}

The state bucket itself is created once (by a small bootstrap configuration or by hand) — it can't be created by the configuration that stores its state in it.

2Locking

Before any operation that might write state (plan with refresh, apply, destroy, import), Terraform acquires a lock. A second run on the same state waits (-lock-timeout=5m) or fails with the lock holder's info (who, when, which operation). This prevents two applies from interleaving API calls and racing to write state.

If a run crashed and left a stale lock, terraform force-unlock <ID> releases it — only after confirming no run is active.

3Protecting the State Bucket

  • Versioning on (recover previous state versions after corruption or accidental deletion).
  • Encryption (SSE-KMS with a dedicated key for tighter access control).
  • Block public access, bucket policy restricted to the CI role and a break-glass admin role.
  • Separate account for state in larger organizations (devops-cloud/aws/… multi-account topic).
  • Access logging / CloudTrail for reads — state contains secrets.

4Splitting State: Blast Radius

One state file for an entire company means every plan touches every resource (slow, risky, lock contention). Split into units (separate root modules, separate state keys) along lines of ownership, change frequency and risk:

text
live/
  global/iam/          → s3://…/global/iam.tfstate
  prod/network/        → s3://…/prod/network.tfstate
  prod/data/           → s3://…/prod/data.tfstate          (RDS, ElastiCache — rare, risky changes)
  prod/services/api/   → s3://…/prod/services/api.tfstate  (frequent changes)
  staging/...
✅
Rule of thumb for units

Split by lifecycle and blast radius: network (rarely changes), data (precious), services (change daily), per environment. A broken apply on a service unit can then never touch the VPC or the database.

5Sharing Data Between Units

Units need each other's outputs (the app needs the VPC's subnet IDs). Options:

hcl
# 1) terraform_remote_state — read another unit's outputs (needs read access to that state = all its secrets)
data "terraform_remote_state" "network" {
  backend = "s3"
  config  = { bucket = "acme-terraform-state", key = "prod/network/terraform.tfstate", region = "eu-west-1" }
}
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids

# 2) data sources by tag — decoupled, no access to the other state
data "aws_subnets" "private" {
  filter { name = "tag:tier"  values = ["private"] }
  filter { name = "vpc-id"    values = [data.aws_vpc.main.id] }
}

# 3) SSM Parameter Store — the network unit publishes, consumers read
data "aws_ssm_parameter" "private_subnets" { name = "/prod/network/private_subnet_ids" }

Data sources or a parameter store are usually preferable to terraform_remote_state: consumers don't need read access to the producer's whole state, and units stay loosely coupled. Tools like Terragrunt add dependency wiring between units on top.

Under the HoodHow S3-Native Locking Works▾

With use_lockfile = true, Terraform creates a lock object next to the state (<key>.tflock) using an S3 conditional write (PutObject with If-None-Match: *): S3 accepts the write only if no object with that key exists, atomically — so exactly one concurrent run can create it. The lock object contains JSON with the lock ID, operation, user and timestamp; other runs get a 412 Precondition Failed, read the object to report who holds the lock, and wait or fail. On completion Terraform deletes the lock object. Before S3 supported conditional writes, the same guarantee came from a DynamoDB table with a conditional PutItem on the LockID key — which is why older configurations still have dynamodb_table in the backend block.

Engineers and CI share one remote state per unit stored in a versioned, encrypted S3 bucket; a conditional-write lock file lets only one run proceed at a time, and units read each other's outputs via data sources or SSM parameters
Engineers and CI share one remote state per unit stored in a versioned, encrypted S3 bucket; a conditional-write lock file lets only one run proceed at a time, and units read each other's outputs via data sources or SSM parameters

⚠️ Common Pitfalls (Part 2)

  1. One state for everything — huge blast radius and lock contention.
  2. State bucket without versioning — one bad write and history is gone.
  3. force-unlock while a run is still active — concurrent applies.
  4. terraform_remote_state everywhere — tight coupling, secrets exposure.
  5. Changing the backend key/path without terraform init -migrate-state — Terraform starts with empty state and plans to recreate everything.

📋 Quick Reference (Part 2)

text
backend "s3" { bucket, key (one per unit), region, encrypt = true, use_lockfile = true }
state bucket: versioning + KMS + public access block + restricted policy (+ separate account)
split by unit: global/iam, env/network, env/data, env/services/*
share outputs: data sources by tag / SSM params > terraform_remote_state
init -migrate-state when moving backends · force-unlock only for stale locks

🧠 Key takeaways

  • State maps addresses → real IDs and caches attributes; plan compares code, state and reality (refresh).
  • State contains secrets: encrypt it, restrict access, and never commit it to Git.
  • Teams need a remote backend with locking (S3 + native lockfile or DynamoDB, HCP Terraform); version and protect the bucket.
  • Split state by lifecycle and blast radius; share data via outputs/data sources thoughtfully.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is Terraform state and why is it needed?core▾

A mapping between configuration and real resources (IDs, attributes, dependencies). Terraform uses it to compute plans and track what it manages. It may contain secrets.

Follow-ups interviewers ask
  • What if state is lost? Terraform loses track of resources; you'd need to import them again.
Q2Why use a remote backend?core▾

Shared state for teams, locking to prevent concurrent applies, encryption, versioning and backups. Example: S3 with native lock files or a DynamoDB lock table, Terraform Cloud, GCS, Azure Storage.

Follow-ups interviewers ask
  • What happens without locking? Two applies can corrupt state or race on resources.
Q3How do you split state?senior▾

By environment and component (network, data, apps) to limit blast radius and speed up plans; share outputs via terraform_remote_state or data sources/SSM parameters.

Follow-ups interviewers ask
  • Downside of one huge state? Slow plans and risky changes affecting everything.
Q4How do you refactor without destroying resources?core▾

Use moved blocks (or terraform state mv) when renaming resources or moving them into modules, import blocks for adopting resources, and removed blocks/state rm to stop managing them.

Follow-ups interviewers ask
  • Why moved blocks over state mv? They're declarative, reviewed in PRs and applied in pipelines.
Q5How do you protect secrets in state?core▾

Encrypt the backend, restrict access tightly, avoid putting secrets in Terraform when possible (generate them in secret managers), and mark outputs sensitive.

Follow-ups interviewers ask
  • Does sensitive hide values from state? No; only from CLI output.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Why is local state a problem for teams?
Use a remote backend with locking.
2. What does state locking prevent?
The second run fails or waits.
3. A teammate deleted a resource in the console. The next plan…
Refresh reconciles state with reality.
4. Why split state into units?
A bad apply can't touch unrelated stacks.