Pulumi vs Terraform: What 18 Months of Production Taught Us
We ran Pulumi in TypeScript and Terraform in HCL side by side across 60+ services. Each won different categories of work. Here's the breakdown.
On this page
We've been running both Pulumi (TypeScript) and Terraform (HCL) in production for 18 months across 60+ services on AWS. Different teams adopted different tools at different times, and we got to compare them under real conditions. Each won different categories of work. Here's the breakdown — the wins, the limits, and what we'd do differently.
The Setup
- Terraform: managed via Atlantis on PRs, state in S3 with DynamoDB locking, 14 root modules.
- Pulumi: TypeScript with shared component packages, Pulumi Cloud for state, 9 stacks per environment.
- Both: integrated with the same CI, same OIDC roles, same secret management.
Where Terraform Won
1. Onboarding Speed
A new engineer can read HCL and figure out what infrastructure exists. Pulumi requires understanding TypeScript, Pulumi's resource graph, and async/await semantics. We measured: median time-to-first-PR was 2 days for Terraform, 4 days for Pulumi.
2. Reading Diffs in Reviews
terraform plan output is uniform. Every reviewer reads the same shape: resource type, name, attributes that change. Pulumi's diff output for complex inputs is harder to scan, especially when inputs are computed expressions.
# Terraform plan — easy to scan
~ resource "aws_security_group_rule" "ingress" {
~ description = "old" -> "new"
}
# Pulumi diff with computed input
~ aws:ec2/securityGroupRule:SecurityGroupRule
~ description: "[computed]" -> "[computed]"
3. Provider Quality
Terraform providers, especially hashicorp/aws, are battle-tested. Pulumi's AWS Classic provider is largely a wrapper around the same backing code, but the abstraction layer above (Crosswalk, etc.) sometimes lags behind direct Terraform usage by weeks for new AWS services.
4. State Recovery
We had a corrupted state once on each side.
- Terraform:
terraform import+terraform state rm/mv— verbose but tractable. Took 90 minutes. - Pulumi:
pulumi state delete/importworks similarly, but the documentation is thinner. Took 4 hours.
5. Hiring
We can find Terraform experience easily. We've never had a candidate list Pulumi on a CV.
Where Pulumi Won
1. Conditional Logic
Anything beyond simple "create N copies" is painful in HCL. Pulumi handles it naturally:
// Pulumi
const subnets = config.requireObject<Subnet[]>("subnets");
for (const s of subnets) {
if (s.public && config.getBoolean("enablePublicAccess")) {
new aws.ec2.Subnet(s.name, { ... });
}
}
# Terraform — works but reads worse
resource "aws_subnet" "public" {
for_each = {
for s in var.subnets : s.name => s
if s.public && var.enable_public_access
}
# ...
}
For complex conditional infrastructure (cross-region failover, feature-flag-driven topologies), Pulumi was clearly easier to read and maintain.
2. Sharing Logic Between Teams
We have a "platform component" that creates a service: ECS task, ALB rules, IAM role, log group, alarms. In Pulumi:
// Published as @ourorg/pulumi-service
export class StandardService extends pulumi.ComponentResource {
constructor(name: string, args: ServiceArgs, opts?: pulumi.ComponentResourceOptions) {
super("ourorg:platform:StandardService", name, {}, opts);
this.task = new aws.ecs.TaskDefinition(`${name}-task`, ..., { parent: this });
this.alarm = new aws.cloudwatch.MetricAlarm(`${name}-alarm`, ..., { parent: this });
// ...
}
}
Versioned, semver, published to our internal npm. Teams npm install it.
The Terraform equivalent (a Terraform module in a git repo with source = "git::...") works but has weaker semver story and worse IDE support.
3. Real Tests
// pulumi/test/service.spec.ts
import { runPreview } from "@pulumi/pulumi/tests";
import * as service from "../src/service";
test("service creates exactly one task definition", async () => {
const result = await runPreview(() => service.create("test", { ... }));
const taskDefs = result.resources.filter(r => r.type === "aws:ecs/taskDefinition:TaskDefinition");
expect(taskDefs).toHaveLength(1);
});
You can write unit tests for Pulumi code with regular test frameworks. Terraform has terraform test (newer, improving) but it's still less expressive than running Jest against TypeScript.
We caught two real bugs in pre-deploy unit tests: an alarm threshold off by 100×, and a tag missing on resources required by our cost allocation policy.
4. Loops Over Cloud APIs
Sometimes you need to provision based on data from outside your config. In Pulumi:
const dnsClient = new awsSdk.Route53({ region: "us-east-1" });
const hostedZones = await dnsClient.listHostedZones().promise();
for (const zone of hostedZones.HostedZones) {
if (zone.Config?.PrivateZone) {
new aws.route53.Record(`record-${zone.Id}`, { ... });
}
}
Terraform requires data sources or external scripts. Awkward.
5. Refactors
Renaming a resource in Terraform requires terraform state mv for every instance. Pulumi has pulumi rename for components.
The Hybrid Pattern We Settled On
After 18 months, we landed on this split:
| Use case | Tool |
|---|---|
| Network/VPC, base IAM, account scaffolding | Terraform |
| Cross-account/cross-region orchestration | Terraform |
| Service-shaped resources (ECS, RDS, ALB) | Pulumi |
| Internal "platform component" packages | Pulumi |
| One-off scripts and computed topology | Pulumi |
| Anything a third party will read (audit, security review) | Terraform |
The bias: base / static / "infrastructure" things → Terraform. Application-shaped / dynamic / "platform" things → Pulumi.
What Hurt Most
Pulumi: Build-time vs Run-time confusion
Pulumi's TypeScript code runs in two modes:
- At preview/up time: regular Node code that builds a resource graph
- In transformations and dynamic providers: limited subset
We had a bug where someone tried to read a file at preview time, but the file only existed inside an apply block. The error message was unhelpful. New engineers stumble on this regularly.
Terraform: HCL evaluation order
Terraform's evaluation order is implicit and sometimes surprising. We hit cases where a depends_on was needed but not obvious; a missing one caused intermittent apply failures (only when state hadn't been refreshed). Pulumi's explicit parent and dependsOn are more predictable.
Both: Drift Detection
Neither tool shines here. Drift caught at plan time is fine. Drift introduced manually then corrected by re-applying days later silently restores config but doesn't report what changed. We supplemented both with a separate drift-detection job that diffs state vs reality on a schedule and posts to Slack.
Numbers After 18 Months
| Metric | Terraform | Pulumi |
|---|---|---|
| Engineers using daily | 22 | 9 |
| Lines of code | 38k | 12k |
| Average plan time | 28s | 47s |
| Provider version bumps causing breakage | 3 | 5 |
| Cross-team component reuse | low | high |
| New-engineer ramp | 2 days | 4 days |
Best Practices For Either
- State backend with locking is non-negotiable. S3+DynamoDB or Pulumi Cloud. Don't run plans without it.
- PR-driven workflow. Atlantis for Terraform, Pulumi's GitHub integration. Never
applyfrom a laptop. - Module/component versioning. Pin versions; bump deliberately; semver matters.
- Tag literally everything. Cost-allocation tags, compliance tags, ownership tags. Both tools support enforcement; use it.
- One stack/workspace per environment. Separating dev/stage/prod by directory or branch is fragile.
- Drift detection on a cron. Neither tool does it well by default; bolt it on.
When To Start With Which
- Start with Terraform if your team is small, your infra is mostly static, you want maximum hireable knowledge, or you need to pass a security audit that asks "do you use IaC."
- Start with Pulumi if you need rich logic, you have engineers who think in code rather than config, you're building reusable platform components, or you're integrating heavily with cloud APIs at provision time.
You can absolutely use both. We do. The cost is split context — engineers need to know both tools eventually. The benefit is using each where it shines.
What We'd Do Differently
- Standardize earlier. We let teams pick freely for the first 6 months. The result: 4 different conventions for the same thing. We'd impose more structure from day one.
- Invest in components, not modules. Building reusable Pulumi components paid off; equivalent investment in Terraform modules paid off less because HCL is more constrained.
- Document the split. New engineers asked "which tool do I use for X" weekly until we wrote down the rules in our runbook.
There's no universal winner. There's the tool that best fits the shape of the work in front of you. After 18 months, we use both, deliberately.
GCP Workload Identity Federation: Replacing Service Account Keys
We deleted every static GCP service account key in our org over six weeks. Here's the migration plan, the gotchas, and the policies we now enforce.
Argo Rollouts: Canary Deployments That Caught a $40k Bug
A two-line config change to an Argo Rollouts analysis template caught a regression that would have cost ~$40k in API spend before we noticed. Here's the pattern.
More from Infrastructure
Explore more articles in this category
AWS EU-SOUTH-2 Packet Loss: The Single-AZ Lesson Nobody Learned
A single Availability Zone in AWS's Spain region degraded for roughly 2.5 hours, and most "multi-AZ" architectures would have failed anyway.
Cloudflare's Clef: When a Decision Model Beats an LLM Call
Most moderation, routing, and triage calls aren't generation problems. Clef proves it by answering in 38.8 milliseconds instead of seconds.
Perplexity Left DynamoDB for CobbleDB: When Should You?
Perplexity built its own key-value store because DynamoDB's read path and bill stopped fitting 50 KB search items. Here is the checklist for when leaving is justified.
You might have missed
Evergreen posts worth revisiting.