DevOpsil

Pulumi vs Terraform: An Honest Comparison from the Trenches

Zara BlackwoodZara Blackwood8 min read

Stop Asking "Which Is Better" — Ask "Which Fits"

I've shipped production infrastructure with both Terraform and Pulumi. Neither is universally better. The right choice depends on your team, your existing stack, and what kind of pain you're willing to tolerate. Here's what I've learned after running both in anger.

The Fundamental Difference

Terraform uses HCL — a declarative DSL purpose-built for infrastructure. Pulumi uses general-purpose languages (TypeScript, Python, Go, C#) to define infrastructure through an SDK.

That one difference cascades into everything.

Terraform: Declaring a VPC

resource "aws_vpc" "main" {
  cidr_block           = var.cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(local.common_tags, {
    Name = "${var.environment}-vpc"
  })
}

resource "aws_subnet" "private" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr, 8, count.index)
  availability_zone = var.azs[count.index]

  tags = merge(local.common_tags, {
    Name = "${var.environment}-private-${var.azs[count.index]}"
    Tier = "private"
  })
}

Pulumi (TypeScript): Same VPC

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

const vpc = new aws.ec2.Vpc("main", {
  cidrBlock: config.cidr,
  enableDnsHostnames: true,
  enableDnsSupport: true,
  tags: { ...commonTags, Name: `${environment}-vpc` },
});

const privateSubnets = azs.map((az, i) => {
  const cidr = `10.0.${i}.0/24`;
  return new aws.ec2.Subnet(`private-${az}`, {
    vpcId: vpc.id,
    cidrBlock: cidr,
    availabilityZone: az,
    tags: { ...commonTags, Name: `${environment}-private-${az}`, Tier: "private" },
  });
});

Both produce identical infrastructure. The difference is in how you express and maintain it.

Where Terraform Wins

1. Ecosystem Maturity

Terraform has thousands of providers, years of community modules, and battle-tested patterns. When you Google "how to deploy X with Terraform," you'll find 10 blog posts, 3 official modules, and a Stack Overflow thread with the exact answer.

Pulumi's ecosystem is growing fast, but it's not there yet.

2. Plan Readability

terraform plan

The output is deterministic, declarative, and readable by anyone — including the security engineer reviewing your PR who doesn't write TypeScript. HCL plans map 1:1 to resource changes. Pulumi's preview is good, but it's harder to reason about when the underlying code uses loops, conditionals, and async operations.

3. State of the Community

# This just works — thousands of teams have validated it
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"

  cluster_name    = "prod"
  cluster_version = "1.29"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets
}

The terraform-aws-modules collection alone covers 80% of what most teams need. Pulumi has component resources and the Pulumi Registry, but the depth isn't comparable yet.

4. Hiring and Onboarding

HCL takes a week to learn. TypeScript Pulumi requires knowing TypeScript, async/await, Pulumi's resource model, and the SDK. If your platform team is two people and the rest are app developers, Terraform's lower barrier matters.

Where Pulumi Wins

1. Real Programming Constructs

Need to generate resources from a JSON config file? Query an API during deployment? Build complex conditional logic?

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

interface ServiceConfig {
  name: string;
  port: number;
  replicas: number;
  needsDatabase: boolean;
}

const services: ServiceConfig[] = JSON.parse(
  fs.readFileSync("services.json", "utf-8")
);

for (const svc of services) {
  const sg = new aws.ec2.SecurityGroup(`${svc.name}-sg`, {
    ingress: [{
      protocol: "tcp",
      fromPort: svc.port,
      toPort: svc.port,
      cidrBlocks: ["10.0.0.0/8"],
    }],
  });

  if (svc.needsDatabase) {
    new aws.rds.Instance(`${svc.name}-db`, {
      engine: "postgres",
      instanceClass: "db.t3.micro",
      allocatedStorage: 20,
      vpcSecurityGroupIds: [sg.id],
    });
  }
}

In Terraform, this requires for_each, dynamic blocks, templatefile, and a lot of HCL gymnastics. In Pulumi, it's just code.

2. Testing

import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect } from "vitest";

describe("VPC", () => {
  it("should have DNS support enabled", async () => {
    const vpc = new aws.ec2.Vpc("test", {
      cidrBlock: "10.0.0.0/16",
      enableDnsSupport: true,
    });

    const dnsSupport = await pulumi.output(vpc.enableDnsSupport).apply(v => v);
    expect(dnsSupport).toBe(true);
  });
});

Terraform has terratest (Go) and the built-in test command (HCL), but Pulumi's testing story is more natural when your infra is already in a language with mature testing frameworks.

3. Sharing Logic Without Modules

In Terraform, reuse means modules — which means inputs, outputs, wiring, and version pinning. In Pulumi, reuse means functions and classes:

function createTaggedBucket(name: string, env: string): aws.s3.Bucket {
  return new aws.s3.Bucket(name, {
    tags: {
      Environment: env,
      ManagedBy: "pulumi",
      Team: "platform",
    },
    serverSideEncryptionConfiguration: {
      rule: {
        applyServerSideEncryptionByDefault: {
          sseAlgorithm: "aws:kms",
        },
      },
    },
  });
}

const dataBucket = createTaggedBucket("data", "prod");
const logsBucket = createTaggedBucket("logs", "prod");

No module registry, no versioning overhead, no source URLs. Just a function.

4. Secret Management

Pulumi encrypts secrets in state by default:

pulumi config set --secret dbPassword "s3cret"

Terraform stores everything in state as plaintext. You can encrypt the state file at rest (S3 + KMS), but secrets inside state are still visible to anyone with state access.

My Decision Framework

FactorChoose TerraformChoose Pulumi
Team knows HCLYes
Team knows TypeScript/PythonYes
Need community modulesYes
Complex generation logicYes
Compliance/audit requirementsYes (mature tooling)
Rapid prototypingYes
Multi-cloud, standard resourcesYes
Dynamic, API-driven infraYes

The Hybrid Approach

Here's what nobody tells you: you can use both. I've run setups where core platform infrastructure (VPCs, IAM, DNS) lives in Terraform — stable, audited, rarely changes — and application-level infrastructure lives in Pulumi where teams can use their own language.

infrastructure/
├── platform/          # Terraform — networking, IAM, shared services
│   ├── networking/
│   ├── iam/
│   └── dns/
└── applications/      # Pulumi — per-team, per-service infra
    ├── api-service/
    ├── worker-service/
    └── data-pipeline/

Pulumi can read Terraform state as a data source, so cross-referencing works.

CI/CD Integration Comparison

Both tools need to run in CI. The experience is notably different.

Terraform in CI

# .github/workflows/terraform.yml
jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform plan -out=tfplan
      - uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: tfplan

  apply:
    needs: plan
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: actions/download-artifact@v4
        with:
          name: tfplan
      - run: terraform init
      - run: terraform apply tfplan

The plan / apply split is natural. The plan artifact is deterministic — what you review is exactly what gets applied.

Pulumi in CI

# .github/workflows/pulumi.yml
jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - uses: pulumi/actions@v5
        with:
          command: preview
          stack-name: prod
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

  deploy:
    needs: preview
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - uses: pulumi/actions@v5
        with:
          command: up
          stack-name: prod
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

Pulumi requires the language runtime (Node.js, Python, Go) in CI. Terraform requires only the Terraform binary. This matters for build speed and container image size.

State Management Comparison

Terraform State

terraform {
  backend "s3" {
    bucket         = "my-state-bucket"
    key            = "prod/networking.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

Self-managed. You set up the S3 bucket, KMS key, and DynamoDB table. You own the security, backups, and access control. Full control, full responsibility.

Pulumi State

# Pulumi Cloud (managed)
pulumi login

# Self-hosted S3 backend
pulumi login s3://my-state-bucket

# Local file backend
pulumi login --local

Pulumi Cloud provides a managed state backend with built-in encryption, audit logging, and a web UI for inspecting state. The free tier covers individual developers. For teams wanting self-managed state, the S3 backend works but lacks the locking sophistication of Terraform + DynamoDB.

Migration Paths

From Terraform to Pulumi

Pulumi provides a conversion tool:

# Convert existing Terraform HCL to Pulumi TypeScript
pulumi convert --from terraform --language typescript

# Import existing Terraform-managed resources
pulumi import aws:s3:Bucket my-bucket my-bucket-name

The conversion is rarely perfect. Complex modules with for_each, dynamic blocks, and templatefile need manual cleanup. Budget 1-2 days per module for conversion and testing.

From Pulumi to Terraform

There's no automated tool. You're reading Pulumi code and rewriting it in HCL. Budget accordingly.

Performance Comparison

MetricTerraformPulumi
Cold start (init)5-15s10-30s (npm install)
Plan/preview (50 resources)10-30s15-45s
Apply/up (50 resources)2-5 min2-5 min
Binary size~80 MBLanguage runtime + SDK
CI image size~150 MB~400 MB (Node.js stack)

Apply time is nearly identical — both are bottlenecked by cloud API calls. The difference shows in plan/preview speed and CI setup overhead.

Conclusion

Terraform is the safe default. It's battle-tested, widely understood, and has the largest ecosystem. Pulumi is the power tool for teams that need real programming constructs and already live in TypeScript or Python. Neither is wrong — but picking the wrong one for your team's skills and your infrastructure's complexity will cost you months. Evaluate honestly, prototype with both, and commit to one primary tool. The worst outcome is half your infra in Terraform and half in Pulumi with nobody owning the boundary.

Share:
Zara Blackwood
Zara Blackwood

Platform Engineer

Terraform enthusiast, platform builder, DRY advocate. I believe infrastructure should be versioned, reviewed, and deployed like any other code. GitOps or bust.

Related Articles