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.
Key takeaways
- 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
Pulumi vs Terraform: What 18 Months of Production Taught Us#
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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
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
Redis vs Memcached: Choosing a Cache in 2026
Both are fast in-memory stores, and both get picked by habit more than by requirements. Here is what actually differs and when each one is the right call.
Vault vs AWS Secrets Manager vs Doppler: Choosing a Secrets Tool
One is a full secrets platform, one is AWS-native and hands-off, and one is built for developer workflow. Picking by feature list alone misses the real tradeoff.
How DNS Works (Explained Simply)
A developer-friendly walk through DNS resolution, record types, TTL, and the caching quirks that cause real production bugs.
You might have missed
Evergreen posts worth revisiting.