All articles
DevOps & CloudOctober 26, 2026 6 min read

Pulumi vs Terraform in 2026: We Migrated Half Our Stacks and Kept the Rest

We spent nine months running Pulumi and Terraform side by side across production. Here's what we moved, what we didn't, and the specific failure modes that made us stop short of a full migration.

We did not set out to run two IaC tools in production. We set out to migrate from Terraform to Pulumi, hit three specific walls, and ended up with a split estate that we're genuinely happy with. This is the honest version of that story — what moved, what stayed, and the numbers that drove each call.

Why we considered leaving Terraform at all

The trigger wasn't HCL fatigue or the licence drama. It was a boring, specific pain: our platform team was writing increasingly elaborate for_each gymnastics to generate per-tenant infrastructure, and reviewing those PRs had become a part-time job. We had around 140 modules across AWS and GCP, a mix of networking, data, and app-tier stacks, and three teams contributing changes weekly.

The hypothesis was simple: if the dynamic bits were in TypeScript, we'd stop fighting the language and get proper types, loops, and tests. Pulumi was the obvious candidate because we already run a lot of TypeScript in our web and backend services.

What we measured before touching anything

Before any migration, we baselined four things:

  • Average PR review time for IaC changes (median ~45 minutes, p90 well over two hours)
  • CI minutes per terraform plan across the monorepo (~18 minutes cold, ~6 warm)
  • Number of drift incidents per month caught by scheduled plans (4–6)
  • Onboarding time for a new engineer to ship their first infra change (around two weeks)

If you skip this step, you'll end up arguing about taste six months in. Numbers settle arguments.

The migration plan we actually followed

We did not do a big-bang rewrite. We picked three categories of stack and moved them in order of expected payoff:

  1. Per-tenant app infrastructure — the stuff drowning in for_each
  2. Developer tooling environments — ephemeral preview stacks
  3. Shared platform — networking, IAM baselines, KMS, DNS

Only categories 1 and 2 ended up on Pulumi. Category 3 is still Terraform, deliberately. More on why below.

Importing existing resources without downtime

The part people underestimate: importing hundreds of live resources into a new tool without touching them. Pulumi's import works, but at scale you want it scripted. We wrote a generator that read our Terraform state and emitted Pulumi import stanzas:

import * as aws from "@pulumi/aws";

// Generated from terraform state pull
const tenantBucket = new aws.s3.BucketV2(
  "tenant-acme-assets",
  {
    bucket: "tenant-acme-assets",
  },
  { import: "tenant-acme-assets", protect: true },
);

Two things bit us here. First, Pulumi's import generates code that often doesn't match the resource exactly — tags, lifecycle rules, and defaults diverge. Every imported resource needed a pulumi preview cycle and manual reconciliation. Budget roughly 10–20 minutes per non-trivial resource, not the 30 seconds the docs imply.

Second, protect: true is non-negotiable during migration. We had one near-miss where a refactor would have replaced an RDS instance. Protect saved us. Leave it on until you're confident, then remove it in a separate PR that does nothing else.

Where Pulumi genuinely won

For per-tenant stacks, the win was real and measurable. Our tenant provisioning module went from ~800 lines of HCL with nested dynamic blocks to about 300 lines of TypeScript with a proper Tenant class. New engineers understood it in an afternoon.

Concrete gains after six months on the migrated stacks:

  • PR review time on tenant infra PRs dropped roughly in half
  • We wrote actual unit tests for the config logic using Jest, which caught two misconfigurations before they hit preview
  • Reusing a shared @ourorg/infra-primitives npm package across stacks eliminated a class of copy-paste drift

The testing bit deserves emphasis. You can test Terraform modules, but the ergonomics are rough. Being able to write a plain Jest test that asserts "a production tenant always gets a KMS key with rotation enabled" is the kind of guardrail that pays for itself the first time someone forgets.

The automation API is the real selling point

For our preview environments, we use Pulumi's Automation API to spin up and tear down stacks from a Node service. We tried this with Terraform using terraform -chdir subprocess calls for years. It worked, but it felt like shelling out to a CLI because we were shelling out to a CLI. The Automation API gives you a programmatic surface with structured errors, event streams, and no .tfvars juggling.

const stack = await LocalWorkspace.createOrSelectStack({
  stackName: `preview-${prNumber}`,
  projectName: "preview-env",
  program: previewProgram(prNumber),
});

await stack.setConfig("region", { value: "eu-west-1" });
const result = await stack.up({ onOutput: console.log });

This replaced about 400 lines of bash and reduced preview-env failure rates noticeably — mostly because we stopped parsing CLI output with regex.

Where we kept Terraform, and why

The shared platform layer — VPCs, Transit Gateway, Org-level IAM, KMS, Route53 zones — stayed on Terraform. Three reasons, in order of weight.

1. The ecosystem gap is still real for low-level AWS. Modules like terraform-aws-modules/vpc/aws have been battle-tested by thousands of orgs. Pulumi has awsx and crosswalk, but for a VPC with IPv6, flow logs to a specific S3 prefix, custom NACLs, and VPC endpoints for seven services, we kept finding edge cases. We could have fixed them, but platform infra is not where you want to be the first user of anything.

2. State blast radius. Platform state is high-value and low-change. Terraform's state model, with S3 + DynamoDB locking we already understood, had years of operational muscle memory behind it. Pulumi Cloud is fine, and self-managed backends work, but introducing a new state system for the most critical layer felt like changing tyres on a moving lorry for no clear benefit.

3. Auditor familiarity. Our SOC 2 auditor had seen Terraform repos before. Not a technical reason, but it saved us a cycle of explanation.

The policy-as-code wash

We expected Pulumi CrossGuard to be a decisive win over Sentinel/OPA. In practice it's roughly a wash. CrossGuard in TypeScript is pleasant to write, but we already had a Conftest + Rego setup running against terraform plan JSON output, and it works across both tools. We now run Conftest against Pulumi's preview JSON too. One policy engine, two IaC tools. Not glamorous, but it works.

The costs we didn't predict

A split estate has carrying costs that nobody warns you about.

  • Two sets of CI pipelines. Our GitHub Actions config roughly doubled in complexity for IaC. We eventually extracted a reusable workflow, but the first three months were messy.
  • Context switching for on-call. When an alert fires at 2am, "is this Terraform or Pulumi?" is a question you don't want to ask. We now tag every resource with iac_tool and surface it in our runbooks.
  • Secrets handling diverged. Terraform used SOPS with KMS; Pulumi uses its own encrypted config. We standardised on pulling from AWS Secrets Manager at apply time for both, which was work we hadn't scoped.
  • Pulumi Cloud pricing. At our resource count it's meaningful. Self-hosting the backend on S3 is possible and we evaluated it, but the Cloud features (RBAC, audit log, deployments) were worth paying for. Price it before you commit.

What the numbers looked like at month nine

Being honest about tradeoffs:

  • Tenant infra velocity: materially better on Pulumi
  • Platform infra velocity: unchanged, which is what we wanted
  • Total CI minutes: up about 15%, mostly from running both toolchains
  • Drift incidents: down, but we attribute most of that to policy-as-code, not the tool switch
  • Onboarding: new engineers now learn both tools, which takes longer than learning one well

If you'd asked us at month three, we'd have said "migrate everything." At month nine, we're glad we didn't.

Where we'd start if you're considering this

If your Terraform pain is specifically about dynamic, repetitive, per-customer or per-environment infrastructure, Pulumi is worth a serious pilot. Pick one stack category, import it properly with protect: true, baseline your metrics first, and give it a full quarter before judging.

If your pain is state management, module quality, or platform reliability, changing tools won't fix it. Fix the pipeline, add policy-as-code, and run drift detection on a schedule. The tool is rarely the problem at that layer.

And if you're starting greenfield in 2026? We'd still reach for Terraform for the platform baseline and Pulumi for the application-tier and tenant-shaped stacks. Boring is a feature where blast radius is highest.

If you want a second pair of eyes on an IaC migration or a platform audit, that's the kind of thing we do on our DevOps and cloud engagements.

#Pulumi#Terraform#IaC#DevOps#AWS#GCP

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project