Skip to main content
Pulumi vs Terraform: What 18 Months of Production Taught Us

Pulumi vs Terraform: What 18 Months of Production Taught Us

KU
• 7 min read•21 views

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.

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.

code
# 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/import works 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:

typescript.typescript
// Pulumi
const subnets = config.requireObject<Subnet[]>("subnets");
for (const s of subnets) {
  if (s.public && config.getBoolean("enablePublicAccess")) {
    new aws.ec2.Subnet(s.name, { ... });
  }
}
hcl.hcl
# 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:

typescript.typescript
// 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

typescript.typescript
// 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:

typescript.typescript
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 caseTool
Network/VPC, base IAM, account scaffoldingTerraform
Cross-account/cross-region orchestrationTerraform
Service-shaped resources (ECS, RDS, ALB)Pulumi
Internal "platform component" packagesPulumi
One-off scripts and computed topologyPulumi
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

MetricTerraformPulumi
Engineers using daily229
Lines of code38k12k
Average plan time28s47s
Provider version bumps causing breakage35
Cross-team component reuselowhigh
New-engineer ramp2 days4 days

Best Practices For Either

  1. State backend with locking is non-negotiable. S3+DynamoDB or Pulumi Cloud. Don't run plans without it.
  2. PR-driven workflow. Atlantis for Terraform, Pulumi's GitHub integration. Never apply from a laptop.
  3. Module/component versioning. Pin versions; bump deliberately; semver matters.
  4. Tag literally everything. Cost-allocation tags, compliance tags, ownership tags. Both tools support enforcement; use it.
  5. One stack/workspace per environment. Separating dev/stage/prod by directory or branch is fragile.
  6. 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.

React
Share this post
KU

Kiril Urbonas

AI Engineer

555 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.