Terraform State Files Explained: Locking, Drift, and What’s Actually in Them

Terraform State Files Explained: Locking, Drift, and What's Actually in Them - Build Archive

The .tfstate file is the part of Terraform that gets glossed over in most getting-started guides, right up until it’s the reason a deploy breaks in a way that has nothing to do with your actual infrastructure config. Neither of the two breaks I hit provisioning a Proxmox container with Terraform was a state problem — this is the failure mode that one didn’t cover. It’s worth understanding state on its own terms: what it is, why a local one stops being fine the moment more than one person or one CI job touches it, and what “drift” actually looks like when you go find it.

State Is Not Configuration — It’s a Cache With Opinions

Your .tf files describe what you want. The state file records what Terraform believes actually exists — every resource ID, every computed attribute, the full JSON representation of the last known-good reality. plan and apply don’t re-derive that picture from the provider API on every run for everything; they diff your desired config against this cached belief, and only fall back to checking the real infrastructure for the specific resources in front of them. That’s the entire reason state exists as a separate artifact instead of Terraform just asking the cloud provider “what do you actually have” every time — it would be correct, and unworkably slow for anything with more than a handful of resources.

The consequence worth internalizing early: state can be wrong. Not corrupted, just stale — a true record of the last time Terraform looked, not a live view of the present.

Why a Local State File Stops Being Fine

A terraform.tfstate sitting next to your .tf files works right up until a second person, or a second CI run, tries to apply against the same infrastructure. Two applies running concurrently against the same local file can corrupt it outright — there’s no coordination mechanism for a plain file on disk. The fix is a remote backend with built-in locking:

terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/network.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-locks"   # this is the locking half
    encrypt        = true
  }
}

The bucket holds the state file itself; the DynamoDB table is what actually prevents two concurrent apply runs from stepping on each other. Terraform writes a lock record before it starts modifying anything and every other apply against that same backend blocks until it clears:

Error: Error acquiring the state lock

Lock Info:
  ID:        7f3e2a91-...
  Path:      my-terraform-state/prod/network.tfstate
  Operation: OperationTypeApply
  Who:       ci-runner-3@github-actions
  Created:   2026-09-04 11:02:17 UTC

That error is the system working, not failing — it’s telling you exactly who holds the lock and when they started, so you can decide whether to wait or go find out why a run from two hours ago never released it. The wrong reaction to seeing this message is terraform force-unlock without checking first; that command exists for the genuine case of a crashed process that never released its lock, and using it on a lock that’s actually still in progress is how two applies really do collide.

Drift: When Reality Stops Matching the Cache

Drift is what happens when something changes the real infrastructure outside of Terraform — someone clicks a setting in a cloud console, a different tool manages the same resource, an auto-scaling event resizes something Terraform thinks is a fixed value. State doesn’t know until you ask it to check:

$ terraform plan -refresh-only

  # aws_instance.web has changed outside of Terraform
  ~ resource "aws_instance" "web" {
        id            = "i-0abc123"
      ~ instance_type = "t3.micro" -> "t3.small"
    }

Plan: 0 to add, 1 to change, 0 to destroy.

-refresh-only is the safe way to ask this question: it updates Terraform’s picture of reality without touching any actual infrastructure, and stops before applying anything. Running a plain terraform apply against drifted state without refreshing first can go the other direction entirely — Terraform sees the live t3.small doesn’t match your config’s t3.micro and will happily resize the instance back down to match your file, silently undoing whatever the manual change was for. Whether that’s the correct outcome depends entirely on whether the manual change was a deliberate exception or an accident — Terraform has no way to tell the difference, which is exactly why checking drift explicitly before applying matters more than trusting the diff to be self-explanatory.

Ignoring Drift on Purpose, When That’s the Right Call

Not every out-of-band change is a mistake to fight. An autoscaling group’s instance count, or a value some other system legitimately owns, is a common case for telling Terraform to stop caring about a specific attribute:

resource "aws_autoscaling_group" "app" {
  # ...
  lifecycle {
    ignore_changes = [desired_capacity]
  }
}

ignore_changes is a scalpel, not a shrug — it should name the exact attribute you know something else legitimately owns, not get reached for as a blanket way to stop a noisy plan output. A resource with too many ignored attributes is usually a sign that something shouldn’t be managed by Terraform at all, or should be split so the parts something else touches live outside its resource block entirely.

The Part That Surprises People: State Files Aren’t Redacted

Worth stating plainly because it’s easy to assume otherwise: a Terraform state file stores every attribute of every managed resource in plain JSON, including anything a provider returns that happens to be sensitive — a generated database password, a TLS private key, an API token issued during creation. Marking a variable sensitive = true hides it from plan and apply console output; it does nothing to the state file itself, which still contains the real value in the clear. This is the actual, concrete reason state needs the same access controls and encryption-at-rest as a secrets store, not just the same durability guarantees as any other piece of infrastructure config — a state file leak is potentially a credentials leak, even for a config that never explicitly printed anything sensitive to a terminal.

The Checklist

  • Local state is fine for a solo experiment; anything more than one person or one CI pipeline touching it needs a remote backend with locking, not just remote storage.
  • A lock error is the system working — check who holds it before reaching for force-unlock, and only use that command on a lock you’ve confirmed is actually stale.
  • Run plan -refresh-only periodically on anything that could be touched outside Terraform — drift you don’t know about is drift that surprises you at the worst possible apply.
  • Reach for ignore_changes on named attributes something else legitimately owns, not as a way to silence a plan you haven’t actually investigated.

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *