DevgainsDevgainsDevgains
All articles

Terraform State Explained: Remote Backends, Locking, and Drift

·9 min read·Updated Jul 10, 2026

Terraform state is the single most misunderstood part of Infrastructure as Code — and the part that causes the most 2am incidents. It is a JSON file that records everything Terraform has created, and if you get it wrong you can duplicate resources, lock yourself out of a deploy, or watch two engineers overwrite each other's infrastructure. This guide is Terraform state explained for developers: what it is, why it exists, and how remote backends, state locking, and drift detection turn it from a fragile local file into a safe, shared source of truth. It builds on the Terraform pillar guide and lives in the Devgains cloud category.

Quick answer: what is Terraform state?

Terraform state is a JSON file that maps the resources declared in your configuration to the real resources that exist in your cloud. When you write a resource "aws_instance" "web" block, Terraform records — in state — the actual instance ID (i-0abc123…) it created for that block. On the next run, state is how Terraform knows that block already corresponds to a running server, so it can update it rather than create a second one.

State does three jobs:

  • Mapping — links each config resource to a real cloud resource ID.
  • Metadata — tracks dependencies and resource attributes so Terraform can order changes.
  • Performance — caches attribute values so plan doesn't have to query every API every time.

The one-line mental model: state is Terraform's memory. Without it, Terraform is amnesiac — it cannot tell "create new" from "modify existing," and every apply would try to build the world from scratch.

Why Terraform state matters

The moment more than one person runs Terraform, state stops being a convenience and becomes a correctness problem. Two failure modes dominate:

  • Lost state = orphaned infrastructure. If the state file is deleted or not shared, Terraform no longer knows those resources exist. Your next apply happily creates duplicates, and the originals become untracked, unmanaged, and still billing you.
  • Concurrent writes = corruption. If two engineers apply against the same state at the same time, the writes interleave and the file becomes inconsistent — a genuinely painful recovery.

Local state (the default terraform.tfstate in your working directory) is fine for a solo tutorial. For anything real — a team, a CI pipeline, production — you need state that is stored remotely, locked during writes, and never committed to Git. That is the difference between IaC as a toy and IaC as infrastructure you can trust.

Architecture: what a backend actually does

A backend determines where Terraform reads and writes state. The default is the local backend (a file on disk). A remote backend stores state in shared, durable storage — an S3 bucket, an Azure Storage container, a GCS bucket, or Terraform Cloud — and, critically, adds locking so only one write can happen at a time.

Here is the shape of a remote-backend workflow:

  terraform apply
        |
        v
  [1] acquire lock  --->  lock table / blob lease
        |                       (someone else has it? wait / error)
        v
  [2] read state    <---  remote store (S3 / Azure Blob / GCS)
        |
        v
  [3] refresh + plan + apply changes to the cloud
        |
        v
  [4] write new state --->  remote store
        |
        v
  [5] release lock

The lock (step 1) is what makes concurrent applies safe: while you hold it, anyone else who runs apply is told the state is locked and by whom. On AWS this is a DynamoDB item; on Azure it is a blob lease; Terraform Cloud manages it for you. Never disable locking to "get unstuck" — that is how state gets corrupted.

Step-by-step: configure a remote backend with locking

Below is a production-shaped AWS S3 backend with DynamoDB locking. The pattern is identical in spirit on Azure and GCP; only the resource types change.

# backend.tf
terraform {
  backend "s3" {
    bucket         = "acme-terraform-state"
    key            = "prod/network/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-locks"  # enables state locking
    encrypt        = true               # encrypt state at rest
  }
}

The key is the path inside the bucket — give every environment and component its own key (prod/network, prod/app, staging/app) so their state files are isolated and can be applied independently. encrypt = true matters because state can contain secrets (database passwords, generated keys) in plaintext.

The backing resources are created once, ideally by a small bootstrap config:

# bootstrap/main.tf  (run once, keep its own local state or a separate bucket)
resource "aws_s3_bucket" "state" {
  bucket = "acme-terraform-state"
}
 
resource "aws_s3_bucket_versioning" "state" {
  bucket = aws_s3_bucket.state.id
  versioning_configuration { status = "Enabled" }  # keep history to recover
}
 
resource "aws_dynamodb_table" "locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
  attribute {
    name = "LockID"
    type = "S"
  }
}

Bucket versioning is your safety net: if a bad apply writes broken state, you can roll back to a previous version. Once the backend is declared, initialize it:

# Migrate existing local state into the remote backend
terraform init -migrate-state
 
# From now on, every plan/apply reads and writes remote state, with locking
terraform plan
terraform apply

Terraform detects the new backend, offers to copy your existing terraform.tfstate into S3, and from then on every run is remote and locked. Your teammates run the same init and share one source of truth.

Drift: when reality and state disagree

Drift is any change to real infrastructure that did not go through Terraform — someone resized a database in the console, an autoscaler changed a value, or another tool edited a tag. State still records the old value, so state and reality have diverged.

Terraform detects drift by refreshing: before planning, it queries the real resources and compares them to state. To see drift without changing anything:

# Show what has changed in the real world vs. what Terraform recorded
terraform plan -refresh-only

A -refresh-only plan reports out-of-band changes and lets you decide: either accept reality (update state to match) or re-apply your config to force infrastructure back to the declared state. In CI, running a scheduled plan and alerting on a non-empty diff is a cheap, powerful drift alarm. This is the same discipline that keeps deploys reproducible — the declared config, not a console click, is the source of truth.

Backend comparison

LocalS3 + DynamoDBAzure BlobTerraform Cloud
Shared across teamNoYesYesYes
LockingNoYes (DynamoDB)Yes (blob lease)Yes (managed)
Encryption at restManualYes (SSE)YesYes
State versioningNoYes (S3 versioning)Yes (blob versions)Yes
Best forSolo / demosAWS teamsAzure teamsAny team, hosted runs

There is no single "best" backend — pick the one that matches where your infrastructure and identity already live. If you are on AWS, S3 + DynamoDB is the canonical choice; on Azure, a Storage container with native lease locking; Terraform Cloud (or OpenTofu-compatible equivalents) if you want managed runs and policy without operating your own state bucket.

Best practices

  • Always use a remote backend with locking for any shared or production infrastructure.
  • Never commit state to Git. Add *.tfstate* to .gitignore. State often contains secrets.
  • Enable encryption and versioning on the state store so you can recover from a bad write.
  • Split state by environment and component (prod/network, staging/app) to shrink blast radius and let teams apply independently.
  • Treat state as append-only history — recover via versioning, not by hand-editing JSON.
  • Automate drift detection with a scheduled plan in CI.

Common mistakes

  • Committing terraform.tfstate to the repo. It leaks secrets and breaks the moment two people push. Gitignore it immediately.
  • Editing state by hand. Reach for terraform state mv, terraform state rm, or import instead of a text editor — manual edits corrupt the dependency graph.
  • One giant state file for everything. A single monolithic state makes every apply slow, risky, and globally locked. Split it.
  • Disabling locking to "unstick" a run. This invites the exact corruption locking prevents. If a lock is genuinely stale, use terraform force-unlock <LOCK_ID> deliberately.
  • Ignoring drift. Untracked console changes accumulate until an apply proposes a surprising, destructive diff. Detect drift early.

Takeaways

  • State is Terraform's memory — it maps your config to real resource IDs so Terraform can update instead of recreate.
  • Remote backends make state shared, durable, and encrypted; locking makes concurrent applies safe.
  • Never commit state to Git, and enable versioning so you can roll back a bad write.
  • Drift is out-of-band change; catch it with terraform plan -refresh-only, ideally on a schedule.
  • Split state by environment and component to limit blast radius and keep applies fast.

FAQ

What is Terraform state? Terraform state is a JSON file that records the mapping between the resources in your configuration and the real resources in your cloud, including their IDs and attributes. It is how Terraform knows what it already manages so it can compute an accurate plan.

Why should I never commit Terraform state to Git? State frequently contains sensitive values — database passwords, private keys, tokens — in plaintext, and Git is the wrong place for secrets. It also breaks collaboration: two people pushing state changes will conflict and corrupt it. Store state in a locked remote backend instead.

What is Terraform state locking and why does it matter? Locking ensures only one apply can write state at a time. Without it, two concurrent applies can interleave writes and corrupt the state file. Remote backends like S3 (with DynamoDB) or Azure Blob provide locking automatically.

What is drift in Terraform? Drift is any difference between your recorded state and the actual infrastructure, usually caused by a manual change made outside Terraform. Run terraform plan -refresh-only to detect it, then either accept reality or re-apply your config to correct it.

How do I move state to a remote backend? Declare a backend block, then run terraform init -migrate-state. Terraform offers to copy your existing local terraform.tfstate into the remote store, after which every run reads and writes remote, locked state.

Conclusion

Terraform state is not an implementation detail you can ignore — it is the source of truth that makes Infrastructure as Code safe to run as a team. Move it to a remote backend with locking and encryption, keep it out of Git, split it by environment, and watch for drift with a scheduled refresh-only plan. Do those five things and the scariest part of Terraform becomes its most dependable. From here, continue with the Terraform pillar, learn how to deploy the containers it provisions, and browse the full cloud category and broader DevOps guides.

References

9 min read

Read next