Skip to main content
RunBook Academy

TerraformXXV · Migrations and Backend ChangesProduction Terraform

State Migrations and Refactoring

Intermediate⏱ ~12 minbash

What you'll learn

  • Explain the cost of corrupting the state file during a migration
  • Sequence a state migration as copy, point, verify, decommission
  • Use terraform state mv, state pull, and state push for refactoring
  • Enable S3 versioning and DynamoDB locking before any state lives in S3
  • Decide when to migrate to Terraform Cloud versus a self-hosted backend

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

The state file is a single JSON document that Terraform believes represents reality. It is the only place where the declared configuration and the running infrastructure are tied together. If the state file is wrong, Terraform is wrong. If the state file is lost, the team is rebuilding the estate from memory.

State migration is the act of moving that document between backends, or refactoring it across workspaces, without losing or corrupting it. The lesson is short because the procedure is short. The cost of getting it wrong is large, which is why the procedure is also strict.

What state migration actually does

A state migration moves the bytes of one state file from one backend to another. The real world is not touched. The configuration is not touched. Only the location of the state changes.

Before                                  After
+-----------------------+              +-----------------------+
| Local backend         |              | S3 backend with       |
| ./terraform.tfstate   |   --copy--   | DynamoDB lock         |
| (single host, no      |              | (shared, versioned,   |
|  lock, no backup)     |              |  locked, backed up)   |
+-----------------------+              +-----------------------+

That copy is what terraform init -migrate-state performs when the backend block changes. The CLI reads the current state, writes it to the new backend, and updates the local cache to point at the new location. The order is: read old, write new, update local pointer. If any step fails partway, the procedure has explicit recovery steps for each step.

The cost of corrupting state during migration

This is the section to read before any migration begins. The cost of corruption scales with how much of the estate Terraform owns.

A corrupted local state means the team has lost the record of one project. The recovery is to re-import every resource from the API, or to restore from a backup if one exists. Neither is fast, but the blast radius is one project.

A corrupted S3 state with no versioning means the team has lost the record of the entire estate Terraform owns. Recovery requires rebuilding the state from the live APIs across every account and region. At 300 resources this is days of work; at 3000 it is weeks; at 30000 it is months. Some resources (databases, KMS keys, secrets) cannot be reconstructed from the API at all and have to be created from scratch, which means downtime.

# READ-ONLY: confirm S3 versioning is on before any state
# is written.
aws s3api get-bucket-versioning \
    --bucket acme-tfstate-prod \
    --query 'Status' \
    --output text
Enabled

If that command returns anything other than Enabled, the migration does not start.

The order: copy, point, verify, decommission

The four-step sequence that any state migration follows. The order is non-negotiable.

  Copy              Point             Verify            Decommission
   |                  |                  |                  |
   v                  v                  v                  v
state lives      CLI now reads       plan is empty      old backend
in old and       from new;           for one full       is empty and
new backends     old is intact       change window;     retired; no
simultaneously   and authoritative   on-call has        stale state
                                    signed off         left behind

Copy. The new backend holds an identical copy of the state from the old. The old is unchanged. Both are readable. Neither operator flow has switched over yet.

Point. The CLI now reads from the new backend. This is when init -migrate-state returns success and the local working directory has a new state pointer. The old backend is still intact and could still be re-pointed to in an emergency.

Verify. terraform plan returns no changes for at least one full change window. The state in the new backend produces the same plan as the state in the old backend would have. No operator has been surprised by a non-empty plan that the old backend would have produced.

Decommission. The old backend is retired. S3 buckets are versioned snapshots; local directories are archived to a backup location. After this step the old backend is the audit artefact, not the source of truth.

A team that skips the verify step is a team that discovers drift during the next change, when rolling back is harder.

Procedure: local to S3 with DynamoDB lock

This is the migration most teams do first. The destination has three resources: an S3 bucket, a DynamoDB table for locking, and an IAM policy that grants the Terraform role access to both.

# versions.tf
terraform {
  required_version = ">= 1.9.0"

  backend "s3" {
    bucket         = "acme-tfstate-prod"
    key            = "global/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "acme-tfstate-lock"
    encrypt        = true
  }
}
# CONFIGURATION: reconfigure with the new backend block,
# answer yes when prompted to copy existing state.
terraform init -migrate-state
Initializing the backend...
Backend configuration changed!

Terraform has detected that the configuration specified for the
backend has changed. Terraform will now check for existing state
in the new backend.

Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous
  "local" backend to the newly configured "s3" backend.
  No existing state was found in the newly configured "s3" backend.
  Do you want to copy this state to the new "s3" backend?
  Enter "yes" or "no".

  Enter a value: yes

Successfully configured the backend "s3"!
Terraform will now use "s3" as the default backend.

The next plan must be empty:

# READ-ONLY: confirm the migrated state produces no diff.
terraform plan -no-color
No changes. Your infrastructure matches the configuration.

If the plan shows changes, the migration has a bug. Restore the old backend pointer (delete the .terraform/ directory and re-run init against the local backend) and investigate before retrying.

Refactoring state: mv, pull, push

State refactoring is the same skill used at a smaller scale. Three commands cover most of it:

  • terraform state mv <source> <destination> — rename a resource, move it between modules, or split it into its own state file. The real world is untouched.
  • terraform state pull > backup.tfstate — read the current state as JSON. The output is suitable for diffing, searching, and storing as a backup.
  • terraform state push backup.tfstate — overwrite the current state with a file. Destructive in a different way than apply: apply changes the world, state push changes what Terraform believes about the world.

state push is the recovery tool when the state is corrupted and a known-good backup exists. It is also the most dangerous state command because it bypasses every safety check Terraform has. Most teams should treat it as a four-eyes operation and limit it to the runbook.

S3 to Terraform Cloud

The migration to Terraform Cloud is the same shape, with different plumbing. The destination is a workspace; the source is the S3 state. The migration is performed through the Terraform Cloud API, not through init -migrate-state:

# CONFIGURATION: configure the new backend (Terraform Cloud).
# The init prompt will copy existing state from S3.
terraform {
  cloud {
    organisation = "acme"
    workspaces {
      name = "production"
    }
  }
}

# Push the current state to the new workspace.
terraform init -migrate-state

The verify step is identical: the plan must be empty for one full change window before the S3 bucket is decommissioned.

When to migrate off Terraform Cloud

Some teams adopt Terraform Cloud, then discover the per-resource pricing model or the lack of fine-grained IAM and want to move back. The migration is the same procedure in reverse, with one caveat: the destination must support the locking model Terraform Cloud used. Self-hosted backends need the same DynamoDB-equivalent lock table before they accept the state.

Production failure modes

  1. Versioning is not enabled on the destination S3 bucket. A partial write corrupts the state and there is no version to restore. Symptom: subsequent plans fail with state file is corrupt.

  2. The DynamoDB lock table is in a different region. The state writes succeed but lock acquisition fails. Symptom: every terraform plan errors with Error acquiring the state lock.

  3. The IAM policy grants only the bucket, not the lock table. Lock acquisition fails for the same reason. Symptom: terraform plan works on a single host but fails for any second host or CI runner.

  4. The state file is migrated before the configuration is correct. The plan after migration shows changes that the previous backend would not have shown. Symptom: the team believes the migration introduced drift.

  5. terraform state push is used as a routine command. A bad JSON overwrites the state and the next apply recreates every resource. Symptom: a terraform apply that destroys production with no prior warning.

  6. The old backend is decommissioned before the verify window. A bug surfaces days later and the rollback path is gone. Symptom: no recovery target when the next plan surprises the team.

What to do in production

  • Enable versioning on every S3 bucket before any state is written.
  • Do not split the DynamoDB lock table out of habit. One table serves any number of state files: Terraform derives the LockID item from the bucket and the key, so two configurations with different keys hold independent locks in the same table and run concurrently. Split it when you want a separate IAM boundary or a separate blast radius, which is a security decision rather than a concurrency one.
  • Treat terraform state push as a runbook operation with two-person review.
  • Keep the old backend alive for at least one full change window after migration. Decommission is the last step, not the first.
  • Take a terraform state pull snapshot before every state push and store it in the version control system alongside the configuration change that triggered it.

Verification

A state migration is verified by three commands:

# READ-ONLY: confirm S3 versioning is on.
aws s3api get-bucket-versioning \
    --bucket acme-tfstate-prod \
    --query 'Status' --output text
Enabled
# READ-ONLY: confirm the lock table exists.
aws dynamodb describe-table \
    --table-name acme-tfstate-lock \
    --query 'Table.TableStatus' --output text
ACTIVE
# READ-ONLY: confirm the migrated state produces no diff.
terraform plan -no-color -detailed-exitcode
echo "exit=$?"
exit=0

The exit code from plan -detailed-exitcode is the canonical “empty plan” signal: 0 means no changes, 1 means an internal error, 2 means changes were found. Migration verification requires exit code 0.

Knowledge check · 7 questions

  1. Q1. Which precondition must be true before any Terraform state file is written to a new S3 bucket?

  2. Q2. What is the correct order of operations for a state migration?

  3. Q3. It is safe to decommission the old backend immediately after terraform init -migrate-state returns success.

  4. Q4. Which of the following are operations on Terraform state that should be treated as runbook operations rather than daily commands? (Select all that apply.)

  5. Q5. A team migrated state from local to S3 with DynamoDB locking. Plans fail with Error acquiring the state lock on every run. What is the most likely cause?

  6. Q6. After migrating state from local to S3, terraform plan shows three resources as needing in-place updates that the local state would not have shown. What is the first step?

  7. Q7. What does terraform state pull produce?

Passing score: 75%. Answers are checked in this browser.