Terraform state: remote backends, locking, drift, and state recovery
For developers who already use Terraform and need to stop treating state as magic. This guide shows how remote backends and locking actually behave, how to diagnose drift versus bad state, and how to recover safely when the state file is wrong or corrupted.
TL;DR — Terraform state is the source of truth Terraform uses to map config to real infrastructure, so the practical fix for most team-scale problems is: move state to a remote backend with locking, versioning, and restricted access, then use
plan -refresh-only,state pull,state rm,import, and backend object version restore to recover from drift or corruption. If two people can runapply, local state is already a liability. Reading time: ~7 min
What it is and where it sits
Terraform state is not just a cache. It is the mapping between resource addresses in your configuration and provider-managed object IDs in the real world. Terraform reads it before planning, updates it after apply, and uses it to decide whether aws_instance.web already exists, whether it should be updated, or whether Terraform thinks it must create a new one.
In practice, state sits between three things:
- your Terraform configuration (
.tffiles) - the provider APIs (
aws,azurerm,google,kubernetes, etc.) - the backend that stores the state snapshot
A remote backend replaces terraform.tfstate on a developer laptop or CI workspace with shared storage such as an object store or Terraform-managed remote state service. Locking prevents two writers from updating the same state at once.
Developer / CI
|
| terraform plan/apply
v
Terraform CLI
| read/write state snapshot
| acquire/release lock
+--------------------> Remote backend (S3, GCS, Azure Blob, HCP Terraform, etc.)
|
| refresh / create / update / destroy
v
Provider plugin
|
v
Cloud / cluster API
What remote state replaces:
- local
terraform.tfstate - ad hoc copying state files between laptops
- "just don’t run apply at the same time" as a coordination strategy
What it does not replace:
- provider API truth; resources can still change outside Terraform
- backups; you still want backend versioning/snapshots
- review; a locked backend does not stop a bad
apply
How it actually works
One realistic end-to-end example
Assume a team manages an AWS VPC with Terraform and stores state in S3 with a DynamoDB lock table. One engineer runs apply from CI while another tries locally. Meanwhile, someone manually changes a security group rule in the AWS console.
Step 1: Terraform initializes the backend
terraform init reads the backend block, configures the state location, and downloads providers.
terraform init -reconfigure
Typical output shape:
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.48.0...
- Installed hashicorp/aws v5.48.0 (signed by HashiCorp)
Terraform has been successfully initialized!
If the backend path changed and you forgot migration flags, you will see something like:
Error: Backend configuration changed
A change in the backend configuration has been detected, which may require
migrating existing state.
If you wish to attempt automatic migration of the state, use "terraform init -migrate-state".
Step 2: First writer acquires a lock
CI starts:
terraform apply -auto-approve
Terraform attempts to create a lock record before writing state. If locking works, other writers wait or fail.
Second engineer runs:
terraform plan -lock-timeout=60s
Typical lock error shape:
Error: Error acquiring the state lock
Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
ID: 8f3b0d8d-0c0a-4e8f-bf6d-4a3a8d2c7f11
Path: my-tf-state/prod/network/terraform.tfstate
Operation: OperationTypeApply
Who: ci@runner-17
Version: 1.8.5
Created: 2026-08-10 11:04:13.221 +0000 UTC
Info:
Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try again.
That is the happy path: the second writer was blocked.
Step 3: Terraform refreshes real infrastructure against state
Before planning changes, Terraform asks the provider API for current resource values. If someone changed a security group rule manually, Terraform sees drift during refresh.
Use this to inspect drift without proposing config changes:
terraform plan -refresh-only
Typical output shape:
aws_security_group.web: Refreshing state... [id=sg-0123456789abcdef0]
Terraform used the selected providers to generate the following execution plan.
~ update in-place
Terraform will perform the following actions:
# aws_security_group.web will be updated in-place
~ resource "aws_security_group" "web" {
id = "sg-0123456789abcdef0"
~ ingress = [
- {
- cidr_blocks = ["0.0.0.0/0"]
- from_port = 22
- protocol = "tcp"
- to_port = 22
},
# (1 unchanged element hidden)
]
}
Plan: 0 to add, 1 to change, 0 to destroy.
That means the state was readable and valid; the real issue is drift, not corruption.
Step 4: State gets corrupted or logically wrong
There are two very different failure modes:
- Backend object is unreadable/truncated/invalid JSON
- State is syntactically valid, but contains wrong bindings — for example a resource was deleted manually, moved between modules without a
movedblock, or imported incorrectly
For unreadable state, commands fail early:
terraform state pull
Possible output shape:
Error: Failed to load state: unsupported state file format: The state file could not be parsed as JSON: invalid character '}' looking for beginning of object key string
For logically wrong state, state pull succeeds, but plan shows nonsense like a destroy/create for an object that still exists.
Step 5: Recovery path depends on which failure you have
For drift only:
- inspect with
terraform plan -refresh-only - if Terraform should own the real-world change, update
.tfto match - if the manual change was wrong, run normal
terraform apply
For missing or wrong bindings:
- remove stale bindings with
terraform state rm ADDRESS - re-import with
terraform import ADDRESS REAL_ID - or add
movedblocks when refactoring resource addresses
For corrupted backend object:
- restore the previous version of the state object from backend version history
- then run
terraform state pullandterraform plan -refresh-onlybefore anyapply
⚠️
terraform force-unlockcan let two writers proceed against the same infrastructure if you use it while another apply is still running. Run it only after you have confirmed the original process is dead: check your CI job status, shell session, and provider-side activity first.
If a lock is stale:
terraform force-unlock 8f3b0d8d-0c0a-4e8f-bf6d-4a3a8d2c7f11
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| One person, throwaway sandbox, no shared CI | Local state is acceptable if the environment is disposable and you can recreate it from scratch. |
| Team-managed shared environment | Use a remote backend with locking and backend-side versioning immediately. |
| CI/CD applies to prod | Remote backend is mandatory; lock, encrypt, and restrict write access to CI and a small break-glass group. |
| Frequent console/manual changes by ops | Keep remote state, but expect regular drift checks with plan -refresh-only; Terraform is still useful, but your process is the problem. |
| Short-lived preview environments per branch | Remote backend still helps, but isolate state by workspace/key/path; do not let previews share a state file. |
| You need cross-stack references | Use remote state outputs carefully, but prefer explicit interfaces; remote state is not a service discovery system. |
You probably do not need a sophisticated backend setup if all of these are true:
- the environment is disposable
- one human touches it
- no CI writes to it
- a full rebuild is cheaper than recovery
The moment one of those stops being true, local state becomes operational debt.
Trade-offs
- Shared remote state and locking reduce race conditions → cost: extra infrastructure, IAM policy work, and occasional lock debugging.
- Backend versioning gives you recovery points → cost: storage growth and the need to document restore steps so people do not restore the wrong version.
- Centralized state works better with CI → cost: tighter coupling to backend availability; if the backend is down,
plan/applyare down. - State encryption at rest protects secrets embedded in state → cost: key management and stricter access paths for debugging.
- Remote state outputs can connect stacks → cost: hidden dependencies and brittle deploy ordering if overused.
- Locking prevents concurrent writes → cost: no parallel mutation of the same state; long applies block everyone else.
A subtle cost experienced teams hit: state is often the only place Terraform remembers generated IDs, so backend access becomes production access in practice. Treat read access to state as sensitive, not harmless.
In practice
Example 1: S3 backend with DynamoDB locking
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
This stores state in one object path and uses a DynamoDB table for locking. The gotcha: backend config cannot use normal input variables in the backend block, so teams usually pass environment-specific values via -backend-config files or separate root modules.
Initialize and migrate existing local state:
terraform init -migrate-state
If you are changing only backend settings and want Terraform to forget old cached backend metadata first:
terraform init -reconfigure
Example 2: Diagnose drift vs bad state
terraform state pull > /tmp/prod.tfstate
terraform plan -refresh-only
terraform state list
terraform state show aws_security_group.web
This sequence answers four different questions: can Terraform read state at all, does the real world differ from state, what addresses exist, and what does Terraform think one object is. The gotcha: state show displays Terraform's current view, not guaranteed live provider truth; use it together with plan -refresh-only, not instead of it.
Example 3: Repair a stale binding by re-importing
⚠️
state rmdoes not delete cloud resources, but it does delete Terraform's record of them. If you remove the wrong address and then runapply, Terraform may try to create a duplicate or destroy/recreate something unexpectedly.
terraform state rm aws_security_group.web
terraform import aws_security_group.web sg-0123456789abcdef0
terraform plan
This is the standard repair when the object still exists in the provider but the state binding is wrong. The gotcha: import only records the ID and provider-read attributes; your .tf must already describe the resource correctly or the next plan will still show changes.
Example 4: Backend object recovery workflow
terraform state pull
# if this fails with parse/format errors, restore the previous object version in your backend
terraform init -reconfigure
terraform state pull > /tmp/restored.tfstate
terraform plan -refresh-only
This is the safest sequence after restoring a previous backend object version. The gotcha: restoring an older state snapshot can reintroduce stale serials or old bindings, so always run a refresh-only plan before a normal apply.
Further reading
- Terraform docs: "State" and "Backends"
- Terraform CLI docs:
terraform statecommand family - Terraform docs: "Import" and "moved" blocks
- Terraform docs: "Lifecycle and Resource Drift"
- AWS docs for S3 Versioning and DynamoDB conditional writes
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI