Adopting Live Infrastructure Into Pulumi Without Recreating It
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
- Push with
import_in place. CI ranpulumi up:create: 2, import: 26. - Remove every
import_and the lookup table, in a second commit. The IDs now live in Pulumi state. 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.- 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
inputsyou can see.ignore_changesis the supported tool and it is the correct one: these fields only mean anything at creation. - A value-neutral
pulumi uprestarted 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 realupafter 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
protectbefore 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_changesacross. They were there for a reason. - Prove it with
--expect-no-changesafter 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.