Adopting Live Infrastructure Into Pulumi Without Recreating It

Twenty-six AWS resources moved from Terraform to Pulumi in one morning, with zero replaced. The import is the easy part. The work is the handful of fields the cloud never reports back, and the one line that would have rebuilt the server.
Author

Benedict Thekkel

Published

October 9, 2026

In Pulumi vs Terraform I wrote that for a home lab, Terraform remains the right call. The same day, 2026-08-05, I moved the first project off it. By 2026-10-09 most of what I run was declared in Pulumi. That deserves an explanation, and then the part that is actually useful: how to move without destroying anything.


Why switch, given that post

The post’s own decision chart had the answer in it: want unit tests on infra logic? Pulumi. That turned out to be the deciding branch, more than I gave it credit for. The reasons the migration notes actually record:

  • Same language as everything else. The repos were already Python managed by uv. Pulumi in Python is one toolchain; HCL is a second one.
  • Tests against mocks. The VPN stack has unit tests, 27 of them now, that run in CI before every preview. The Proxmox stack has one asserting that the container is protected, so nobody can quietly remove the guard.
  • Same provider underneath. The Proxmox and AWS providers are bridged from the Terraform ones, so resource schemas and import IDs are identical. Nothing has to be re-learned about the resources themselves.
  • No SaaS. State lives in S3 or a local file, so there is no new token to hold.

None of those say Terraform was wrong. They are about fit. The post’s conclusion was too strong for my own situation, and this one is the correction.


The estate

A WireGuard VPN exit node on EC2 with everything around it: security group, Elastic IP, IAM role and instance profile for SSM, VPC flow logs, a Route 53 record and health check, SNS topics, six CloudWatch alarms, a dashboard and saved Logs Insights queries. 26 resources, all live, all serving traffic, applied by GitHub Actions through OIDC.

The constraint: nothing gets recreated. A new EC2 instance means a new host key, new WireGuard state, and every client reconfigured. A new Elastic IP is worse.


The mechanism: import_ with a lookup table

Pulumi offers two ways to adopt existing resources: the pulumi import CLI, which generates code, and the import_ resource option, which attaches an existing ID to code you have already written. I used the second, because the code was being ported by hand anyway, one resource for one resource:

# adopt.py - deleted once the import landed
LIVE = {
    "instance": "i-0abc...",
    "security_group": "sg-0abc...",
    # ... 26 entries
}

def adopt(key):
    return LIVE.get(key)
instance = aws.ec2.Instance("vpn",
    ...,
    opts=pulumi.ResourceOptions(import_=adopt("instance")))

import_ has a property that makes it safe: Pulumi refuses the import if the live resource differs from your inputs. It does not adopt and then “fix”. So a clean preview is not just permission to proceed, it is proof that the code describes reality.

Every resource also gets an explicit physical name. Pulumi auto-names resources by appending a random suffix, so an unnamed aws.sns.Topic("alerts") would not match the live topic called alerts, and the plan would be to create a new one.


What the first preview wanted to do

It wanted to replace the EC2 instance. The rest of the work was making it stop wanting that.

1. A computed subnet picked a different one. The Terraform config chose a subnet with a data source. The port used min(subnet_ids), which is a perfectly reasonable expression that happened to pick a different subnet from the one the instance actually lives in. Changing an instance’s subnet forces replacement. The fix was to pin the subnet in stack config. The lesson is general: anything the old tool computed at plan time has to be re-checked against what it actually chose.

2. Fields the API never returns. SNS email subscriptions diffed on confirmationTimeoutInMinutes and endpointAutoConfirms. Those are provider-side settings, not properties AWS stores, so an imported subscription reads them back as empty. They went into ignore_changes.

3. Fields Terraform was already ignoring. The old config had lifecycle { ignore_changes = [ami, user_data, key_name] } on the instance, because a new AMI release should not rebuild the VPN. That has to be carried over explicitly, or the first up after a new Amazon Linux image is a replacement:

opts=pulumi.ResourceOptions(
    import_=adopt("instance"),
    ignore_changes=["ami", "userData", "keyName"])

4. Text that is not ASCII. Some live alarm descriptions contained an em dash, typed into the console months earlier. The description must match byte for byte or it is an update. The source writes it as a Unicode escape, so the file stays ASCII and the value still matches AWS.

After those four, the preview read 26 import, 0 update, 0 replace, 0 delete. The only creates were the stack itself and an explicit provider for us-east-1, which billing metrics require.


Landing it

  1. Push with import_ in place. CI ran pulumi up: create: 2, import: 26.
  2. Remove every import_ and the lookup table, in a second commit. The IDs now live in Pulumi state.
  3. pulumi preview --expect-no-changes. This is the check that matters, because it fails the command if anything at all would change. It passed: 28 unchanged.
  4. Watch the next two CI runs report same: 28.

Both commits landed on 2026-10-09, eleven minutes apart.


Retiring Terraform: keep the state

The tempting last step is deleting terraform.tfstate. I kept it, untouched, next to the new state in the same S3 bucket.

The reasoning: if anyone, including me in six months on a stale checkout, runs terraform apply against the old config, a missing state file makes Terraform think nothing exists and create a duplicate of everything. With the state present, the same command is a no-op. Old state is not dead weight. It is what makes the old tool harmless.

The .tf files were removed from the repo. The CI workflow swapped tools without touching auth: the existing OIDC role already had access to the state bucket, so the new workflow assumes the same role. destroy was removed from the manual trigger while I was there.


The same thing on Proxmox, and one more trap

The first migration, the one on 2026-08-05, adopted two Proxmox LXC containers with the CLI (pulumi import proxmoxve:CT/container:Container ...). It hit the same class of problem harder: three creation-only fields (the initial user account, the template the container was built from, and its feature flags) that Proxmox never reports back. Every preview wanted to replace both containers. protect=True blocked it, and ignore_changes on those fields fixed it.

Two lessons from that one that I would not have guessed:

  • Hand-editing the exported state did nothing. The bridged provider diffs against the raw Terraform state it keeps internally, not the inputs you can see. ignore_changes is the supported tool and it is the correct one: these fields only mean anything at creation.
  • A value-neutral pulumi up restarted both containers. It wrote provider defaults the import had not recorded (console settings, start-on-boot), and the provider applies those by restarting. About ten seconds of downtime for a change that changed nothing. Do the first real up after an import at a time when a restart is acceptable.

The newer stacks bake all of this in from the start, driven by an environment variable so that the same program either adopts or creates:

opts=pulumi.ResourceOptions(
    provider=provider,
    protect=True,
    import_=os.environ.get("IMPORT_ID"),   # "<node>/<vmid>" to adopt; unset to create
    ignore_changes=["initialization.userAccount",
                    "operatingSystem.templateFileId"])

Takeaway

The checklist for adopting live infrastructure into any IaC tool:

  • Set protect before the first preview, so a plan that wants to replace something stops instead of applying.
  • Name every resource explicitly. Auto-naming and import do not mix.
  • Treat every proposed replacement as a finding. Find the field. It is nearly always something computed, creation-only, or never reported back.
  • Carry the old tool’s ignore_changes across. They were there for a reason.
  • Prove it with --expect-no-changes after the import options are gone.
  • Keep the old state. It is what stops the old tool from duplicating everything.

The import took a morning. Almost all of it was the four fields.


Back to top