Skip to main content
RunBook Academy

TerraformXXV · Migrations and Backend ChangesProduction Terraform

Migration Strategy for 300+ Resources

Advanced⏱ ~14 minbash

What you'll learn

  • Identify the source of truth for each resource category before migration begins
  • Group resources by owner, environment, and blast radius for the rollout plan
  • Choose between terraform import, state mv, and recreate-and-import per resource type
  • Sequence the migration so read-only state adoption precedes any destructive change
  • Define the cutover criteria and rollback trigger for each batch

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.

A migration to Terraform is not one change. It is several hundred small changes that share a single plan file, a single state file, and a single on-call rotation. The strategy exists to make the blast radius of each batch small enough that a mistake at three in the morning does not become a regional outage.

This lesson is about planning. The hands-on lessons later in this module cover the specific commands. Here the question is how to decide the order of operations across 300 resources sitting in three environments, owned by four teams, and managed by a mix of console clicks, bash scripts, and a competitor’s IaC tool.

What the migration is actually changing

You are not moving infrastructure. The infrastructure is already running. You are moving the record of what exists. Before Terraform, the record lives in one or more of these places:

  • A cloud console. The provider’s web UI is the source of truth; the team remembers which buttons were pressed.
  • A runbook. The steps to rebuild are written in markdown, but no tool checks them.
  • A bash script. The script describes how to make it, not what exists. Re-running it is not safe.
  • A competitor’s IaC state. AWS CloudFormation, Azure ARM/Bicep, Pulumi, or another Terraform root.

Terraform will replace those records with a single state file that the new code agrees with. The work is to reconcile the new declaration with the existing reality, without rebuilding anything by accident.

Per-source strategy

The right approach depends on what you are replacing. There is no single answer for a 300-resource estate.

From manual console and runbooks

There is no state file. Every resource must be imported one by one, or via an import block, and the configuration must be written from scratch by reading what the API returns. This is the most labour-intensive path. Expect days of effort per hundred resources for anything beyond trivial types like IAM roles and security groups.

From bash scripts

The scripts describe steps, not state. Treat them as a hint about what the team intended to build, then import the actual resources that exist. Do not assume the script and reality agree; they often do not, and the script is usually the loser in that argument.

From CloudFormation, ARM/Bicep, Pulumi

These tools already have state. The migration has two viable patterns:

  1. Import from the live API. Treat the other tool as a viewer. Import the resources Terraform needs to manage, then decommission the old tool when the import is complete and the plan is empty.
  2. Reverse-engineer the other tool’s state. Read the CloudFormation stack or Pulumi state file, generate HCL from it, and reconcile. Tools like cfn-tf and the Pulumi state export can speed this up, but you inherit the other tool’s drift and quirks.

For most production estates, option 1 is the safer choice. It forces you to write HCL that matches your team’s conventions rather than the conventions of the previous tool, and it makes the migration auditable one resource at a time.

The four phases of any migration

Every successful Terraform migration follows the same shape, regardless of size:

Inventory          Classify         Adopt in batch    Cut over
 (what exists)     (what risk)      (read-only)       (destructive)
     |                |                 |                 |
     v                v                 v                 v
   list resources  group by owner   terraform import   terraform apply
   from APIs       and blast        + verify plan      destructive plan
                   radius           is empty           only

Phases one and two are non-destructive. They are work you can do without the on-call rotation knowing. Phase three is mostly non-destructive, but the first plan of each batch is the moment you discover what the importer actually adopted. If the importer got it wrong, the plan that follows will tell you before anything changes. Phase four is the only phase that touches the running infrastructure, and only after phases one through three are complete.

Ordering rule: read-only state first, then destructive

The cardinal rule is that you must never let Terraform take a destructive action against an imported resource until you have verified that the import is correct. The verification is the plan: a correctly imported resource produces an empty plan when the configuration matches the live API.

That gives you three safe states and one unsafe transition:

Safe   - Resource exists in the API, not in Terraform state
Safe   - Resource exists in both, configuration matches the
         API (empty plan)
Safe   - Resource exists in both, configuration does not match
         the API (the plan is read; you can choose to apply or
         to edit the configuration)
Unsafe - Terraform believes the resource should not exist, and
         the plan says "destroy"

The unsafe transition happens when you write the configuration before you import. Do not write a resource "aws_instance" "web" \{\} block until the import for aws_instance.web is in state and the plan is empty.

Grouping: by owner, by environment, by blast radius

For 300 resources, the cut-over batches should not be sized by resource count. They should be sized by risk:

GroupingTypical batch sizeRisk class
Per-team, per-environment5 to 30 resourcesBounded; one team to consult
Per-stack (network, compute, data)10 to 50 resourcesMid; blast radius within a layer
Read-only network topology50 to 200 resourcesLow; no destructive operations
Stateful data storesAlways one resourceHigh; corruption means data loss

The high-risk group (databases, object storage with data, KMS keys, secrets) is imported last and one at a time, with the backup verified before the import. The reasoning is that an importer mistake against a stateful resource can lose customer data, and a misconfigured KMS key can lock the team out of production credentials. The cost of being careful is a few extra days. The cost of being fast is a P0 incident.

Tools per phase

The migration uses three Terraform features, each with a specific job:

  • terraform import <address> <id> — adopt a single resource that already exists in the provider API.
  • import { ... } block — same operation, declarative, can be reviewed in the plan, supports for_each for bulk adoption. Available since Terraform 1.5.
  • moved { ... } block — tell Terraform that an address in state corresponds to a different address in configuration (a rename, a module move, a workspace split). No resource is touched.
  • terraform state mv — the same operation as the moved block, run as a one-off command against the state file. Prefer the moved block in code review; prefer state mv for ad-hoc recovery when the state is in a state no configuration can describe.

For resources with no data (S3 buckets in dev, IAM roles, security groups), the for_each import pattern lets you adopt hundreds of resources in a single apply and is the workhorse of any large migration.

Cutover criteria

For each batch, define in advance:

  1. What is the empty plan that proves the import is correct? It must be a hash of the configuration that the team has reviewed and a tag in the version control system.
  2. What is the rollback trigger? A non-empty plan that includes destroy, a terraform plan that fails with a provider error, a state lock that does not release within five minutes — any of these aborts the batch.
  3. Who has the on-call phone? The team that owns the resources, not the migration team. The migration team can be asleep.
  4. What is the sign-off artefact? A git tag on the configuration that corresponds to the empty plan, and a timestamped note in the change log stating who approved and when.

Without these, a migration that succeeds technically will fail operationally. The team that owns the production resources needs to know that something changed and that the change is recoverable.

Production failure modes

These are the failures that have actually broken Terraform migrations in production.

  1. The configuration is written before the import. The resource block declares instance_type = "t3.micro", the live API says t3.small, the plan shows an in-place update, and the apply replaces the instance. Symptom: a non-empty plan after import that includes updates or destroys.

  2. The wrong ID is used at import. terraform import aws_instance.web i-0123456789abcdef0 against the wrong instance ID adopts the wrong resource. Symptom: the empty plan shows different tags, a different subnet, or a different security group than expected.

  3. The old tool still runs alongside. The CloudFormation stack or Pulumi program applies in parallel and overwrites Terraform’s drift detection. Symptom: the plan shows changes that no one in the Terraform workflow made.

  4. The lock is not migrated. The new backend uses S3 without DynamoDB, or the lock table is in a different region. Symptom: two operators run terraform apply at the same time and both succeed, corrupting the state file.

  5. State versioning is off. The destination S3 bucket has no versioning, and a partial write corrupts the state mid-migration. Symptom: subsequent plans fail with Error: state file is corrupt and there is no version to roll back to.

  6. The cutover is announced but not gated. The migration team tells the on-call “we’re done”, and the next change window includes resources that have not been imported. Symptom: the next terraform apply against the unimported resource creates a duplicate.

What to do in production

  • Inventory the resources by querying the provider APIs directly. The cloud console is not authoritative; the API is.
  • Group by owner first, environment second, resource type third. Owners are the bottleneck; types are not.
  • Always verify the empty plan on a non-production copy of the state before touching production state.
  • Cut over in batches of 5 to 30 resources, not 300.
  • Tag every batch with a git tag that corresponds to the empty plan. The tag is the audit artefact.
  • Decommission the old tool only after the empty plan has held for at least one full change window.

Verification

A migration strategy is verified by walking the plan for the first batch, before any apply:

# READ-ONLY: confirm the inventory matches what the team
# believes exists.
aws resourcegroupstaggingapi get-resources \
    --tag-filters Key=Owner,Values=team-platform \
    --output json | jq '.ResourceTagMappingList | length'
# READ-ONLY: confirm the import produced no diff.
terraform plan -no-color -out=batch-001.plan
terraform show -no-color batch-001.plan
Plan: 0 to add, 0 to change, 0 to destroy.
# READ-ONLY: confirm the new backend is the one Terraform will
# read from for the next apply.
terraform state list | wc -l
terraform output -no-color

If the plan shows anything other than the expected number of additions (and that number is the count of newly imported resources for the batch, not 300), the strategy has a bug. The next batch does not start until the bug is identified and either the importer is fixed or the configuration is corrected.

Knowledge check · 7 questions

  1. Q1. A team has 200 AWS resources managed by CloudFormation. Which is the safer migration pattern?

  2. Q2. What proves that a single resource has been imported correctly?

  3. Q3. Writing the Terraform configuration block for a resource before importing it is safe as long as the configuration matches the live API at the time of writing.

  4. Q4. Which of the following are Terraform features used to adopt or refactor existing infrastructure during a migration? (Select all that apply.)

  5. Q5. Which group of resources should be imported last during a production migration?

  6. Q6. During batch 3 of a migration, terraform plan shows one imported resource scheduled for destruction. The resource was imported an hour ago. What is the first action?

  7. Q7. Why is running the previous IaC tool (CloudFormation, Pulumi) alongside Terraform during the migration risky?

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