Skip to main content
tfmigrate-migration-patterns.md6.2 KB
View on GitHub

Tfmigrate Migration Patterns

Use these patterns when writing tfmigrate files for Atmos Terraform components. Atmos invokes tfmigrate from the resolved component working directory after init and workspace selection.

Single-State Migrations

Use migration "state" for moves inside one Terraform state.

migration "state" "rename_security_groups" {
actions = [
"mv aws_security_group.web aws_security_group.app",
"mv aws_security_group.worker aws_security_group.jobs",
]
}

Set dir only when the migration needs a working directory different from the command working directory.

migration "state" "rename_from_subdir" {
dir = "components/terraform/vpc"
actions = [
"mv aws_subnet.private aws_subnet.private_primary",
]
}

Set workspace only when not relying on Atmos workspace selection.

migration "state" "workspace_specific_move" {
workspace = env.ATMOS_TERRAFORM_WORKSPACE
actions = [
"mv aws_route_table.private aws_route_table.private_primary",
]
}

Common Actions

Rename or move addresses:

migration "state" "move_to_module" {
actions = [
"mv aws_iam_role.app module.iam.aws_iam_role.app",
]
}

Remove state bindings without destroying remote infrastructure:

migration "state" "forget_legacy_object" {
actions = [
"rm aws_s3_bucket_ownership_controls.legacy",
]
}

Import existing infrastructure into a new address:

migration "state" "import_existing_bucket" {
actions = [
"import aws_s3_bucket.logs my-company-logs",
]
}

Replace provider source addresses:

migration "state" "replace_provider" {
actions = [
"replace-provider registry.terraform.io/-/aws registry.terraform.io/hashicorp/aws",
]
}

Wildcard moves with xmv:

migration "state" "rename_many_instances" {
actions = [
"xmv aws_security_group.* aws_security_group.$${1}_primary",
]
}

for_each and Count Addresses

Quote addresses with embedded string keys so the shell-like action parser preserves them.

migration "state" "count_to_for_each" {
actions = [
"mv aws_subnet.private[0] 'aws_subnet.private[\"az-a\"]'",
"mv aws_subnet.private[1] 'aws_subnet.private[\"az-b\"]'",
]
}

Keep exact source addresses from atmos terraform state list. Do not infer index or key names from Terraform code.

Multi-State Migrations

Use migration "multi_state" when splitting, merging, or moving resources between state files or component directories.

migration "multi_state" "move_dns_to_dns_component" {
from_dir = "components/terraform/network"
to_dir = "components/terraform/dns"
actions = [
"mv aws_route53_zone.primary aws_route53_zone.primary",
"mv aws_route53_record.app aws_route53_record.app",
]
}

Use wildcard multi-state moves only after confirming the matched addresses.

migration "multi_state" "move_all_dns_resources" {
from_dir = "components/terraform/network"
to_dir = "components/terraform/dns"
actions = [
"xmv aws_route53_*.* $1",
]
}

Variables: Atmos passes its generated varfile to tfmigrate's internal convergence-check plans, in both from_dir and to_dir. This works correctly when both components share the same effective variables. That's the common case: one component's state relocating. When from_dir and to_dir have materially different variables, set from_skip_plan = true and to_skip_plan = true on the migration block instead of relying on the shared varfile. tfmigrate has no way to apply a different -var-file to each side's plan.

Output-only diffs: in the usual multi-state pattern, from_dir no longer declares the resource that moved out. Removing that resource from the config also removes any output that referenced it. tfmigrate's convergence check then flags this as an "unexpected diff," even though no resource attribute changed. Set force = true on the migration block when this is the only diff. Verify this from the error output: it should show only Changes to Outputs, and no resource changes. The same gotcha applies to any migration that changes an output's shape, for example count to for_each.

History Configuration

History mode applies unapplied files from migration_dir in filename order and records applied migrations in durable storage. Use this mode for hooks and CI.

tfmigrate {
migration_dir = "./tfmigrate"

history {
storage "s3" {
bucket = env.ATMOS_TFMIGRATE_HISTORY_BUCKET
key = env.ATMOS_TFMIGRATE_HISTORY_KEY
region = env.ATMOS_TFMIGRATE_HISTORY_REGION
role_arn = env.ATMOS_TFMIGRATE_HISTORY_ROLE_ARN
}
}
}

For GCS-backed Terraform state, Atmos exposes the bucket value as ATMOS_TFMIGRATE_HISTORY_BUCKET:

tfmigrate {
migration_dir = "./tfmigrate"

history {
storage "gcs" {
bucket = env.ATMOS_TFMIGRATE_HISTORY_BUCKET
key = env.ATMOS_TFMIGRATE_HISTORY_KEY
}
}
}

Local history is acceptable only when the file is persisted across reruns:

tfmigrate {
migration_dir = "./tfmigrate"

history {
storage "local" {
path = env.ATMOS_TFMIGRATE_HISTORY_PATH
}
}
}

Atmos Hook Examples

Single-file hook:

hooks:
state-migration:
events:
- before.terraform.plan
- before.terraform.apply
kind: tfmigrate
migration: 20260527090000_refactor.hcl
mode: dynamic

History-mode hook:

hooks:
state-migration:
events:
- before.terraform.plan
- before.terraform.apply
kind: tfmigrate
config: .tfmigrate.hcl
mode: dynamic

Hook with explicit backend config entries passed to tfmigrate:

hooks:
state-migration:
events:
- before.terraform.plan
- before.terraform.apply
kind: tfmigrate
config: .tfmigrate.hcl
backend_config:
- bucket=my-state-bucket
- region=us-east-1

Review Checklist

  • Migration file has exactly one migration block.
  • File extension is .hcl or .json.
  • Filename is sortable when history mode is used.
  • All addresses are copied from state output or reviewed Terraform plan output.
  • plan succeeds before apply.
  • Post-migration Terraform plan does not show unexpected replacement, destroy, or create operations.
  • Durable history storage is configured for hooks and CI.