How to Migrate IaC State Between Backends

Moving state between backends is the riskiest routine operation in Python IaC — a botched cutover orphans resources or duplicates them — so this guide walks the export/import and pull/push procedures for a verified, zero-downtime migration, part of Managing IaC State for Python Projects under Python IaC Fundamentals & Strategy.

You migrate state when you outgrow a local backend, consolidate onto a managed one, or change clouds. The cardinal rule is that the resources never change — only the ledger's location does — so every step is built around proving the new backend describes exactly the same infrastructure as the old one. Decide where you are migrating to first using Choosing a State Backend for Python IaC.

Context

A migration has three phases: freeze (stop all deploys so state cannot change mid-flight), transfer (export from the source, import to the destination), and verify (a no-op plan must show zero changes). Pulumi uses stack export/stack import; CDKTF delegates to Terraform's state pull/state push. Both keep a backup so you can roll back.

Prerequisites

  • Python 3.9+ with the pulumi CLI or cdktf CLI plus the Terraform binary.
  • Read access to the source backend and write access to the destination backend.
  • A maintenance window or a deploy freeze announced to the team.
  • Versioning enabled on the destination store so a bad push can be rolled back.
  • A verified backup of the current state (the very first step below).

Implementation

1. Freeze deploys and back up

Nothing may mutate state during the migration. Capture a backup before touching anything.

# Pulumi: snapshot the current stack state to a local file.
pulumi stack select prod
pulumi stack export --file prod-backup.json

# CDKTF/Terraform: pull current state from the source backend.
terraform -chdir=cdktf.out/stacks/prod state pull > prod-backup.tfstate
# State implication: these files are the rollback target — store them safely; they contain secrets.
from dataclasses import dataclass
from pathlib import Path
import json

@dataclass(frozen=True)
class StateBackup:
    path: Path

    def resource_count(self) -> int:
        # CLI Context: python -c "from migrate import StateBackup, Path; \
        #   print(StateBackup(Path('prod-backup.json')).resource_count())"
        # State implication: record this count now; it must match after migration.
        data = json.loads(self.path.read_text())
        # Pulumi export nests resources under deployment.resources
        return len(data.get("deployment", {}).get("resources", []))

2. Transfer to the new backend

Point the tool at the destination, then import the snapshot.

# Pulumi: log into the new backend, recreate the stack, import the snapshot.
pulumi login s3://my-new-iac-state
pulumi stack init prod
pulumi stack import --file prod-backup.json
# State implication: import writes the snapshot verbatim — no resources are created or destroyed.
from constructs import Construct
from cdktf import TerraformStack

class MigratedStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str) -> None:
        super().__init__(scope, ns)
        # Provider note: change ONLY the backend block; resources stay identical.
        self.add_override("terraform.backend", {"s3": {
            "bucket": "my-new-iac-state",
            "key": f"iac/{ns}/terraform.tfstate",
            "region": "us-east-1",
            "dynamodb_table": "iac-locks",
            "encrypt": True,
        }})
        # CLI Context: cdktf synth, then init with -migrate-state (next step).

3. Re-initialize and push (CDKTF/Terraform)

Terraform can migrate state during init when the backend block changes.

# Terraform offers to copy existing state into the new backend on init.
cdktf synth
terraform -chdir=cdktf.out/stacks/prod init -migrate-state
# Or push an explicit backup if doing it manually:
terraform -chdir=cdktf.out/stacks/prod state push prod-backup.tfstate
# State implication: -migrate-state copies state; it does not modify real resources.

Verification

A correct migration produces a plan with zero changes. This is the single most important check.

# Pulumi: a no-op preview proves the new state matches reality.
pulumi preview --diff        # must report: no changes

# CDKTF/Terraform: plan must show 0 to add, 0 to change, 0 to destroy.
terraform -chdir=cdktf.out/stacks/prod plan
import subprocess

def assert_no_drift(stack_dir: str) -> None:
    # CLI Context: python -c "from migrate import assert_no_drift; assert_no_drift('cdktf.out/stacks/prod')"
    # State implication: exit code 0 with -detailed-exitcode means zero drift; 2 means changes pending.
    result = subprocess.run(
        ["terraform", f"-chdir={stack_dir}", "plan", "-detailed-exitcode"],
        capture_output=True, text=True,
    )
    assert result.returncode == 0, f"Migration left drift: {result.stdout}"

Only after a clean no-op plan should you lift the deploy freeze and decommission the old backend.

Gotchas & Edge Cases

A non-empty plan after import means the migration is wrong. If pulumi preview or terraform plan shows changes, do NOT apply. The most common cause is a region or provider config mismatch between source and destination. Re-check the backend block, fix it, and re-verify before unfreezing.

Pulumi secret provider changes break decryption. Importing into a backend with a different secrets provider can leave secrets unreadable. Migrate the secrets provider explicitly (pulumi stack change-secrets-provider) or keep the same provider during cutover.

Forgetting to delete the old state invites split-brain. If two backends both hold live state and someone runs against the old one, you get divergent, conflicting deploys. Only after verification, archive and then remove the source state object.

Frequently Asked Questions

Is state migration zero-downtime? Yes for the running infrastructure — resources are never touched. The only "downtime" is a freeze on deploys during the transfer, which should last minutes.

How do I roll back a failed migration? Re-import the backup you captured in step 1 (pulumi stack import --file prod-backup.json or terraform state push prod-backup.tfstate) into the original backend, then verify with a no-op plan.

Can I migrate Pulumi state into a CDKTF/Terraform backend? No. The formats are incompatible. Moving between Pulumi and Terraform means re-importing resources with pulumi import or terraform import, not a state copy.

Do I need to stop the application during migration? No. Only IaC deploys are frozen; the workloads themselves keep serving traffic because no cloud resources are modified.