Pulumi vs Terraform for a Multi-Account AWS Migration: What Actually Hurt
We migrated a five-account AWS estate from Terraform to Pulumi, then partially back. Here's the honest breakdown of what each tool did well, where they bled us, and which choice we'd make again.

We inherited a Terraform monorepo running across five AWS accounts, tried to rewrite the whole thing in Pulumi over a quarter, and ended up in a split-tool state we're mostly happy with. This isn't a verdict piece — both tools work. It's a breakdown of where each one cost us real hours, real money, and a couple of weekends.
The starting estate
The environment was typical for a mid-sized SaaS: a management account, a shared-services account (CI runners, ECR, Route53), and separate dev/staging/prod accounts. Roughly 180 Terraform modules, a mix of local modules and versioned ones in a private registry, and an S3 + DynamoDB backend per account. CI was GitHub Actions with OIDC into each account.
The pain points that pushed us to evaluate Pulumi:
- Module composition felt fragile. Passing complex objects between modules meant flattening to strings or abusing
for_each. - Testing was thin.
terraform planin CI caught syntax, not logic. - Our platform team wanted to write reusable abstractions in TypeScript, which the app teams already knew.
We scoped a migration for two accounts (shared-services and dev) and left staging/prod on Terraform until we had confidence.
What Pulumi got right
Real code, real tests
The biggest win was writing infrastructure in TypeScript and testing it. We wrote unit tests for our IAM policy builder using Pulumi's mocks, and they caught two overly permissive Resource: * grants before they hit any account. That's not something we ever bothered doing with Terraform — the ergonomics of Terratest against real AWS meant tests took 15+ minutes and got skipped.
import * as pulumi from "@pulumi/pulumi";
import { buildServiceRole } from "./iam";
pulumi.runtime.setMocks({
newResource: (args) => ({ id: `${args.name}_id`, state: args.inputs }),
call: (args) => args.inputs,
});
test("service role denies wildcard S3 actions", async () => {
const role = buildServiceRole("payments", { bucket: "payments-prod" });
const policy = await new Promise((r) => role.inlinePolicy.apply(r));
expect(JSON.stringify(policy)).not.toContain('"s3:*"');
});
Component resources beat modules
Pulumi's ComponentResource gave us a cleaner abstraction than Terraform modules. We built a WebService component that wrapped an ECS service, target group, log group, alarms, and a secrets policy. Consumers wrote about 12 lines to stand up a new service. The equivalent Terraform module was 40+ lines of variables and outputs plumbing.
Secrets handling
Pulumi's config encryption with a per-stack KMS key was cleaner than SOPS-plus-Terraform, which we'd been maintaining with a helper script and a .gitignore we didn't fully trust.
What Pulumi cost us
State backend surprises
We used the Pulumi Cloud backend initially because self-hosting felt like premature optimization. Then we hit a soft rate limit during a parallel CI run that stacked 14 previews in about 90 seconds. Pulumi Cloud paused updates for a few minutes. Not catastrophic, but embarrassing during a demo.
We moved to the S3 backend with a passphrase-based secrets provider for non-prod, and Pulumi Cloud for prod only. That split created its own headache: two ways to run pulumi login in CI, two sets of docs for engineers.
Provider drift is real
The @pulumi/aws provider is generated from the Terraform AWS provider, so it lags. In one case we needed a new EventBridge Pipes attribute that had been in the Terraform provider for a week and wasn't in Pulumi yet. We worked around it by dropping into aws-native for that one resource, which meant a different resource type and different import semantics. Manageable, but the kind of paper cut that adds up.
Blast radius on refactors
Renaming a component in TypeScript is easy. Renaming it in a way that doesn't cause Pulumi to destroy and recreate the underlying resources requires aliases, and we forgot them twice. Once on a NAT gateway, which took our dev VPC offline for about 20 minutes. pulumi preview showed the delete + create clearly — someone approved anyway. That's on us, not the tool, but the fact that a variable rename in code can nuke infra is a class of risk Terraform users are already trained on and Pulumi users have to learn fresh.
Where Terraform still won
The ecosystem is not close
When we needed a hardened EKS module, a Karpenter setup, or an Aurora Serverless v2 pattern, the Terraform registry had a battle-tested module. Pulumi has equivalents, but they're often thinner, less starred, and sometimes maintained by one person. We ended up porting Terraform modules by hand more than we expected.
terraform plan output is easier to review
This sounds minor. It isn't. In a PR review, a Terraform plan is a flat, greppable diff. Pulumi previews in TypeScript projects can hide changes behind computed values that show up as output<string> until apply. Reviewers missed changes because the diff didn't render them cleanly. We eventually added a pulumi preview --diff --json step piped through a formatter, but that's work Terraform gave us for free.
Onboarding contractors
We brought in two contractors mid-project. Both knew Terraform. Neither had used Pulumi. Getting them productive in the Pulumi codebase took about a week of pairing each. In Terraform, they were opening PRs on day two. If your team composition rotates, this matters more than the language ergonomics.
The numbers, roughly
We don't publish benchmarks we can't reproduce, so treat these as our experience on this specific migration:
- Lines of IaC dropped ~30% in Pulumi for equivalent functionality, mostly from component reuse.
- CI time for a full preview went up ~20% in Pulumi (Node startup, plugin download, larger dep tree).
- Time-to-first-PR for a new engineer: about the same if they knew TypeScript, noticeably longer if they didn't.
- Incidents caused by IaC changes during the migration quarter: 3 (one NAT, one IAM boundary, one Route53 TTL). Two were on the Pulumi side, one on Terraform. Small sample — don't over-read it.
Where we landed
We kept Pulumi for the shared-services account and for anything that's genuinely application-adjacent: ECS services, Lambda functions, per-service IAM, EventBridge wiring. The TypeScript ergonomics and testability pay off there.
We kept Terraform for the foundation: VPCs, Transit Gateway, IAM Identity Center, org-level SCPs, Route53 zones, KMS keys. This stuff changes rarely, benefits from the mature module ecosystem, and doesn't need programming-language flexibility.
The two tools coexist via remote state references. Pulumi reads Terraform state from S3 for VPC IDs and subnet IDs; nothing writes to the other tool's state.
const network = new pulumi.StackReference("network", {
name: "terraform-remote-state/network-prod",
});
const vpcId = network.getOutput("vpc_id");
Is it clean? No. It's two tools, two CI workflows, two mental models. But each tool is doing what it's good at, and we stopped trying to force one to cover the whole surface.
What we'd do on a greenfield today
If we were starting fresh in 2026 with a small platform team and no legacy: Terraform (or OpenTofu) for the foundation, Pulumi for the application layer, from day one. Write down the boundary between them before you write any code. The boundary is the hard part — the tools are fine.
If you're on Terraform now and it hurts, the honest answer is that migrating everything to Pulumi will probably cost you more than fixing your Terraform. Better module hygiene, a proper testing story with Terratest or tflint plus policy-as-code, and a stricter review process will get you 70% of what you think Pulumi will give you. Reserve the rewrite for the layer where dynamic composition actually matters.
If you want a second pair of eyes on your IaC boundary, our DevOps and cloud engineering team has been in this exact spot more than once.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

GCP Cloud Run Min-Instances vs Startup CPU Boost: Which One Actually Fixes Cold Starts
We ran a Node and a Java service on Cloud Run through both knobs — min-instances and Startup CPU Boost — to see which one actually earns its keep. The answer depends on your runtime, your traffic shape, and how honest you are about idle cost.
Sentry Performance Quotas Bit Us Mid-Incident: A Rate Limiting Postmortem
During a payment outage, Sentry silently dropped 40% of our transactions right when we needed them most. Here's what tripped the quota, how we found out, and the rate-limiting setup we run now.
Terraform State Locking on S3 Native: What We Learned After Ditching DynamoDB
HashiCorp finally shipped native S3 state locking. We migrated three production stacks off DynamoDB and hit two sharp edges you should know about before you follow.
