Pulumi vs Terraform: An Honest Comparison from the Trenches
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
| Factor | Choose Terraform | Choose Pulumi |
|---|---|---|
| Team knows HCL | Yes | — |
| Team knows TypeScript/Python | — | Yes |
| Need community modules | Yes | — |
| Complex generation logic | — | Yes |
| Compliance/audit requirements | Yes (mature tooling) | — |
| Rapid prototyping | — | Yes |
| Multi-cloud, standard resources | Yes | — |
| Dynamic, API-driven infra | — | Yes |
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
| Metric | Terraform | Pulumi |
|---|---|---|
| Cold start (init) | 5-15s | 10-30s (npm install) |
| Plan/preview (50 resources) | 10-30s | 15-45s |
| Apply/up (50 resources) | 2-5 min | 2-5 min |
| Binary size | ~80 MB | Language 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.
Related Articles
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
Crossplane: Managing Cloud Infrastructure from Kubernetes
How to use Crossplane to provision and manage cloud infrastructure using Kubernetes-native APIs — one control plane to rule them all.
Terraform from Zero to Production: Project Structure, Modules, State, and CI/CD
Build production-grade Terraform infrastructure — project structure, module design, state management, testing, and CI/CD pipeline integration.
Terraform Module Design Patterns for Large Teams
Battle-tested Terraform module patterns for teams — from file structure to versioning to composition. If it's not in code, it doesn't exist.