TerraformXXV · Migrations and Backend ChangesProduction Terraform
State Migrations and Refactoring
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
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 thanapply:applychanges the world,state pushchanges 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
-
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. -
The DynamoDB lock table is in a different region. The state writes succeed but lock acquisition fails. Symptom: every
terraform planerrors withError acquiring the state lock. -
The IAM policy grants only the bucket, not the lock table. Lock acquisition fails for the same reason. Symptom:
terraform planworks on a single host but fails for any second host or CI runner. -
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.
-
terraform state pushis used as a routine command. A bad JSON overwrites the state and the next apply recreates every resource. Symptom: aterraform applythat destroys production with no prior warning. -
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
LockIDitem 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 pushas 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 pullsnapshot 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
Q1. Which precondition must be true before any Terraform state file is written to a new S3 bucket?
Q2. What is the correct order of operations for a state migration?
Q3. It is safe to decommission the old backend immediately after terraform init -migrate-state returns success.
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.)
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?
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?
Q7. What does terraform state pull produce?
Passing score: 75%. Answers are checked in this browser.