Pulumi vs Terraform in 2026: A Migration We Half-Regretted
We migrated a mid-size AWS + Vercel estate from Terraform to Pulumi. Six months in, here's what actually broke, what got better, and the parts we'd do differently.
Last spring we moved a mid-size infrastructure estate — roughly 180 AWS resources, a Vercel org, and a Cloudflare zone — from Terraform to Pulumi (TypeScript). Six months later we've shipped features faster, but we've also had two incidents that would not have happened on Terraform. Here's the honest scorecard.
Why we migrated at all
The pitch to ourselves was straightforward. Our platform team writes TypeScript every day. Our HCL modules had grown into a small DSL of for_each, dynamic blocks, and templatefile() calls that felt increasingly like a programming language written by someone who hated programmers.
The specific pain points:
- Conditional resources were painful. Every
count = var.enabled ? 1 : 0pattern leaked into[0]index references downstream. - Cross-stack references through
terraform_remote_statewere fragile and made refactors expensive. - Testing was essentially
terraform planin CI and hoping.terratestexisted but nobody used it. - Onboarding. New hires understood a Pulumi program in an afternoon. HCL took a week before they stopped fighting it.
We did not migrate because Terraform was slow, or because of the Business Source License drama — OpenTofu was a perfectly reasonable option and we evaluated it seriously. We migrated because we wanted infrastructure code that behaved like the rest of our code.
The migration itself
We did not rewrite everything. We used pulumi import for stable resources (VPCs, IAM baselines, Route53 zones) and rewrote the churnier layers (ECS services, Lambda, Vercel projects) from scratch in TypeScript.
The import phase was the slow part
pulumi import generates code, but the code it generates is verbose and doesn't match the abstractions you actually want. For roughly 60 imported resources, we spent about two weeks cleaning up generated code, extracting components, and reconciling drift the tool surfaced along the way.
A representative moment: importing an existing ALB revealed three listener rules that had been added manually two years ago and never made it back into Terraform. Nobody remembered why. This is not Pulumi's fault — it's what happens when you shine a light on a corner of the estate — but budget for it.
// What we ended up with after cleanup
import * as aws from "@pulumi/aws";
export class ApiService extends pulumi.ComponentResource {
public readonly url: pulumi.Output<string>;
constructor(name: string, args: ApiServiceArgs, opts?: pulumi.ComponentResourceOptions) {
super("platform:api:Service", name, {}, opts);
const tg = new aws.lb.TargetGroup(`${name}-tg`, {
port: 8080,
protocol: "HTTP",
vpcId: args.vpcId,
healthCheck: { path: "/healthz", matcher: "200" },
}, { parent: this });
// ...listener rules, ECS service, autoscaling all as children
this.url = tg.arn;
this.registerOutputs({ url: this.url });
}
}
Component resources are the feature we missed most in Terraform. They give you a real parent/child tree in state, which makes pulumi destroy on a single logical service actually work.
Where Pulumi has genuinely been better
Refactoring
Renaming a resource in Terraform means moved blocks or terraform state mv gymnastics. In Pulumi, aliases on a resource work, and because you're in a real language, the IDE tells you what else needs to change. We did two large-scale renames in the first quarter that would have been a full sprint of HCL toil.
Cross-provider composition
Our Vercel projects need environment variables pulled from AWS SSM, which need values from a Cloudflare API token, which is stored in 1Password. In TypeScript this is just async code. In Terraform it was three providers, two data sources, and a null_resource with a local-exec we all pretended not to see.
Testing
We now write unit tests for our component resources using Pulumi's mocks. They run in milliseconds and catch regressions like "you forgot to attach the security group". Not a replacement for a real preview, but a genuine safety net.
pulumi.runtime.setMocks({
newResource: (args) => ({ id: `${args.name}-id`, state: args.inputs }),
call: () => ({}),
});
test("api service attaches security group", async () => {
const svc = new ApiService("test", { vpcId: "vpc-123", sgId: "sg-456" });
const sgId = await promiseOf(svc.securityGroupId);
expect(sgId).toBe("sg-456");
});
Where we half-regret it
This is the part vendor blogs skip.
The state backend is a real decision
Pulumi Cloud is the default, and it's good. But it's also a SaaS bill and a dependency on a service that has had outages. We evaluated self-hosting against S3 with DynamoDB locking (like our old Terraform setup) and landed on Pulumi Cloud for the team features. Six months in, we've hit their API rate limits twice during large parallel deploys. Terraform's S3 backend never rate-limited us because it wasn't a service — it was a bucket.
If you're cost-sensitive or compliance-constrained, self-hosted state on S3 is a legitimate choice, but you lose the web UI, RBAC, and secrets encryption that make Pulumi Cloud pleasant.
The blast radius of a bug is bigger
A Terraform module is constrained by HCL. A Pulumi program is TypeScript, which means someone can — and did — write an await inside a loop that made 400 sequential AWS API calls during pulumi up. It worked. It was slow. It cost us a 45-minute deploy before we found it.
HCL's limitations are also guardrails. When you take them away, you need code review discipline you didn't need before. We now have lint rules that flag for loops over resource creation and require pulumi.all([...]) for parallel work.
Incident: the diff that wasn't
The worst moment was a deploy where pulumi preview showed "no changes" but pulumi up recreated an RDS read replica. Root cause: a helper function returned a different object identity on each invocation, and one of its fields was fed into a resource input that Pulumi's diff engine considered a replacement trigger. The preview cached the first computation; the up re-ran it.
We caught it in staging. On production it would have been a 20-minute read outage. The fix was to memoize the helper, but the deeper lesson was: in a real programming language, non-determinism is your problem, not the tool's. Terraform's declarative-only world makes this class of bug impossible.
CI cost went up
Our CI runners now install Node, the Pulumi CLI, and a full node_modules for each stack. Cold-cache runs went from about 45 seconds (Terraform) to around 2 minutes (Pulumi). We fixed most of it with aggressive caching, but it's a real tax. Over a month of preview environments, our CI minutes bill rose noticeably — not catastrophically, but enough to notice on the invoice.
The comparison, honestly
| Concern | Terraform / OpenTofu | Pulumi |
|---|---|---|
| Onboarding a TS engineer | Slow | Fast |
| Onboarding an ops engineer | Fast | Medium |
| Refactoring at scale | Painful | Pleasant |
| Guardrails against bad code | Strong (by limitation) | Weak (needs review) |
| State backend options | Many, cheap | Pulumi Cloud or DIY |
| Ecosystem / modules | Enormous | Growing, uneven |
| Debugging a bad diff | Hard but bounded | Hard and unbounded |
Neither tool is obviously better. If your team is majority ops-leaning and your infra is stable, Terraform or OpenTofu is the boring, correct choice. If your team writes application code all day and your infra changes weekly, Pulumi will pay for itself — with the caveats above.
What we'd do differently
If we ran the migration again:
- Start with a single non-critical stack for a full quarter before committing. We committed after six weeks and hit surprises we'd have caught with more runway.
- Write the lint rules on day one, not month three.
no-await-in-loopand "all resource inputs must come from typed component args" would have saved us the RDS incident. - Budget explicitly for the import cleanup phase. It is not a weekend job.
- Keep OpenTofu on the table for the boring stacks. We now run our DNS and IAM baseline in OpenTofu and everything else in Pulumi. Mixed estates are fine.
We help teams work through exactly this kind of decision on our DevOps and cloud engagements — usually the answer isn't the tool, it's the guardrails you put around it. If you're considering the move, run a small stack for a quarter, write the lint rules first, and don't let anyone tell you HCL's limitations aren't sometimes a feature.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
The Terraform State Lock That Held Our Deploys Hostage for 6 Hours
A stuck DynamoDB lock froze every pipeline in the org. Here's what actually happened, why our runbook made it worse, and the guardrails we put in afterward.
The S3 Egress Bill That Doubled Overnight: Tracing a Rogue Prefetch
A quiet Tuesday, a doubled AWS bill, and a client-side prefetch nobody remembered shipping. Here's how we traced 3.2 TB of surprise S3 egress back to a single React hook.
GCP Cloud Run vs AWS Fargate for Bursty APIs: What We Learned Running Both
We ran the same Node API on Cloud Run and Fargate for six months. Cold starts, egress costs, autoscaling behaviour, and the operational papercuts nobody warns you about.
