Two engineers run terraform apply on the same stack within the same minute. Both read the state, both plan against it, both write back. The second write silently overwrites the first, and now the state file describes infrastructure that no longer matches reality. Nobody gets an error. This is the failure mode local state was never built to prevent, and the reason remote backends exist.
Why local state does not survive a team
Terraform keeps a state file that maps every resource in your configuration to a real object in your provider, plus metadata and dependency order. By default that file is terraform.tfstate, sitting on the laptop of whoever ran the last apply.
That default breaks the moment a second person joins:
- It is not shared. Your colleague’s state does not know about the resources you created. Their next plan wants to recreate them.
- It has no lock. Nothing stops two applies from running at once, and Terraform’s read-modify-write cycle is not atomic across machines.
- It leaks secrets. State stores resource attributes in plaintext, including generated passwords and keys, now scattered across laptops and possibly Git.
A remote backend fixes all three: one authoritative copy, an access-controlled store, and a locking mechanism.
What a remote backend gives you
A backend is declared in the terraform block. The S3 backend is the common reference:
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "platform/prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true # native S3 locking, Terraform 1.10+
}
}
State now lives in S3, versioned and encrypted, and locking is handled by a lock file written next to it using S3 conditional writes. Historically that lock lived in a separate DynamoDB table (dynamodb_table with a LockID primary key); the DynamoDB approach still works, but native lock files removed the extra dependency for new stacks.
Other backends bundle the same idea differently. The GCS backend has locking built in, using object generation numbers, no side table required. HCP Terraform (formerly Terraform Cloud) manages the state store and the lock for you, and adds run history and access policy on top.
How the lock actually works
Before any operation that could write state (apply, and plan when it refreshes), Terraform acquires a lock. The lock is a small record holding who took it, which operation, when, and a unique ID. While it is held, a second run refuses to start:
$ terraform apply
╷
│ Error: Error acquiring the state lock
│
│ Lock Info:
│ ID: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d
│ Operation: OperationTypeApply
│ Who: alice@build-runner-07
│ Created: 2026-07-24 09:14:22 UTC
╵
This is the system working correctly. The lock is released automatically when the operation finishes, whether it succeeds or fails cleanly.
The classic trap: the stale lock
The lock is only released cleanly if Terraform gets to release it. Kill the process with Ctrl-C twice, lose the network mid-apply, or have a CI runner evicted, and the lock stays held with no owner behind it. Every subsequent run then fails with the same Error acquiring the state lock, blocking the whole team.
The escape hatch is force-unlock, using the ID from the error:
terraform force-unlock a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d
Treat this as a loaded gun. Force-unlocking while another apply is genuinely still running reintroduces the exact concurrent-write corruption the lock existed to prevent. Confirm the owning process is truly dead (check the CI job, ask the person named in Who) before you unlock. Never wire force-unlock into automation, and never reach for -lock=false to make the error go away, that disables locking for real applies.
Drift, and why plan is your detector
State assumes it is the only thing changing your infrastructure. When someone edits a resource by hand in a console, or another tool touches it, the real world diverges from what state records. That gap is drift.
terraform plan is the detector: it refreshes state against the provider’s real attributes, then compares config, state, and reality. A refresh-only run isolates pure drift from changes you actually intend:
terraform plan -refresh-only
If that shows differences with no config changes on your side, something outside Terraform moved. Reconcile deliberately, either by importing and codifying the change or by applying to pull reality back to the declared state. Running drift detection on a schedule, as a step in your CI/CD pipeline, turns silent divergence into an alert instead of a surprise during the next unrelated deploy.
What to remember
Local state is a single-player default. The moment a second person or a pipeline touches the same infrastructure, you need a remote backend for a shared source of truth and a lock to serialize writes. Locking is not an obstacle to route around: the -lock=false flag and a reflexive force-unlock are how teams corrupt state. And because state trusts that it is the only actor, schedule plan to catch drift before it catches you. Getting this right is foundational to industrializing your CI/CD on top of infrastructure as code.