DevOps · Terraform

Terraform State Management — A Field Guide

Terraform's state file is the single source of truth for what it thinks exists — get the backend, locking, and import story wrong and you get corrupted state or infrastructure Terraform can't see.

John Kihiu12 min read

Terraform's state file is not an implementation detail — it's the only record Terraform has of what it thinks exists and how your configuration maps to real resource IDs. Every plan, every apply, every destroy reads that file first. Lose it, corrupt it, or let two people write to it at once, and Terraform stops being able to tell you the truth about your infrastructure. Most of the pain I've seen with Terraform in teams has nothing to do with HCL syntax — it's state management done carelessly.

Why local state doesn't survive contact with a team

The default backend is local: a terraform.tfstate file sitting next to your .tf files. That's fine solo, and it falls apart the moment a second person runs apply. Nobody has the latest state, there's no locking, and the file itself often contains secrets in plaintext (database passwords, private keys) that now live in whoever's home directory happened to run Terraform last. A remote backend fixes all three problems: the state lives in one shared location, Terraform can lock it during an operation, and you can restrict who can read it.

The most common setup is an S3 bucket for storage paired with a DynamoDB table for locking. Terraform writes the state to S3 and, before any operation that touches it, takes out a lock row in DynamoDB. If someone else's apply is already running, yours waits or fails cleanly instead of racing it.

HCL
terraform {
  backend "s3" {
    bucket         = "acme-terraform-state"
    key            = "prod/networking/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

The alternative worth knowing is Terraform Cloud or HCP Terraform, which gives you remote state, locking, and a run history with no DynamoDB table to manage yourself. For a small team already paying for HCP Terraform's remote execution, it's less infrastructure to babysit than rolling your own S3+DynamoDB backend. For everyone else already on AWS, S3 and DynamoDB are two resources you likely already know how to operate, and they cost close to nothing.

What locking actually prevents

State locking exists for one reason: two concurrent applies writing to the same state file will corrupt it. Terraform's plan/apply cycle reads the state, computes a diff against your config and the real infrastructure, then writes a new state back. If a second process reads the same "before" state while the first is still applying, both processes compute plans against a state that's about to be stale, and whichever one writes last wins — silently discarding the other's changes, or worse, writing a state file that no longer matches reality at all.

With DynamoDB-backed locking, the lock is just a conditional write to a row keyed by the state file's path. Terraform acquires it before reading state and releases it after writing. If you've ever seen Error acquiring the state lock, that's the mechanism working correctly, not a bug — it means another run genuinely holds the lock.

Do not hand-edit the state file, and do not force-unlock casually

The state file is JSON, so it's tempting to open it and fix something by hand. Don't — a single misplaced resource address or dependency edge desyncs Terraform's model from reality in a way that's hard to diagnose later. Use terraform state mv, terraform state rm, or the import block instead. Similarly, terraform force-unlock is for the case where a run genuinely crashed and left a stale lock behind — running it because your apply "seems stuck" while another process is legitimately mid-apply will let two applies race each other and corrupt the state.

Bringing existing infrastructure under management

Not everything starts life in Terraform. You inherit resources someone clicked into existence in the console, or you're incrementally migrating a stack one resource at a time. terraform import has long been the way to tell Terraform "this resource exists, here's its ID, add it to state" — but it only touches state, not your .tf files, so you still have to hand-write a matching resource block and hope the attributes line up.

The newer import block, added in Terraform 1.5, is a better fit for anything beyond a one-off: it's declarative, goes in your regular configuration, and shows up in terraform plan as an explicit import action you can review before it happens, the same as any other change.

HCL
import {
  to = aws_security_group.app
  id = "sg-0abc123456789def0"
}

resource "aws_security_group" "app" {
  name        = "app-sg"
  description = "Security group for the app tier"
  vpc_id      = var.vpc_id
}

Run terraform plan -generate-config-out=generated.tf alongside an import block and Terraform will write a best-effort resource block for you, which saves the tedious part of matching every attribute by hand. Always review the generated config — it's a starting point, not something to apply blind.

Splitting state to limit blast radius

A single root module with one state file for an entire environment is the easiest way to end up terrified of running terraform apply. If your VPC, your RDS instance, and your Kubernetes cluster all live in one state, a bad plan on an unrelated change can still touch (or worse, propose destroying) something critical, and a corrupted state file takes everything down with it, not just one component.

The fix is to split state along ownership and blast-radius lines: one root module (and one state file) for networking, another for data stores, another per service or team. Terraform workspaces can help here too, but workspaces are for multiple environments of the *same* configuration (dev/staging/prod), not for splitting unrelated infrastructure — don't reach for workspaces to solve a "state is too big" problem, reach for more root modules. Cross-module references then go through terraform_remote_state data sources or, more robustly, through fixed outputs like SSM parameters or explicit IDs passed as variables.

Smaller state means smaller plans to review

Splitting state isn't just about safety during an apply — it makes terraform plan output reviewable again. A plan against a 400-resource monolith state is something people rubber-stamp because reading it properly takes an hour. A plan against a 20-resource module is something people actually read.

Detecting drift before it detects you

Drift is what happens when reality and state disagree — someone changed a security group rule in the console during an incident, an autoscaling event modified a tag, or another tool touched a resource Terraform also manages. Terraform doesn't watch your infrastructure continuously; it only finds out about drift when you run terraform plan, which refreshes its view of real infrastructure and diffs it against state and config. An unexpected diff — a change you didn't make, showing up as something Terraform now wants to "fix" — is your drift signal.

The practical habit is running terraform plan on a schedule (a nightly CI job is enough for most teams) even when nobody intends to apply anything, purely to catch drift while it's still one resource and not fifteen. Left unchecked, drift compounds: the plan output gets noisier, people start ignoring it because "it always shows changes," and the one plan that matters gets lost in the noise. If a change was made out-of-band deliberately, either codify it in Terraform immediately or, if it should stay unmanaged, pull the resource out of state with terraform state rm rather than leaving Terraform fighting reality every run.

Wrapping up

None of this is exotic: a remote backend with locking, careful imports instead of hand-edited state, state split along real ownership boundaries, and a habit of running plan even when you don't intend to apply. What makes state management hard in practice isn't any single piece — it's that state failures are silent until they aren't, and by the time terraform apply tells you something is wrong, the state file and reality may have already diverged in ways that take real archaeology to untangle. Treat the state file with the same seriousness as a production database, because for Terraform's purposes, that's exactly what it is.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.