Terraform vs Pulumi vs CDK: An Infrastructure as Code Autopsy
Three years running three IaC stacks in production — one per tool. This post compares Terraform, Pulumi, and AWS CDK on state management, drift detection, testing, multi-cloud, and the specific workflows that made us switch. Spoiler: there is no winner, only tradeoffs.
# Terraform vs Pulumi vs CDK: An Infrastructure as Code Autopsy
I've spent the last three years running three different IaC stacks in production — one for each of our platform teams. Not by design. It started as a migration that got interrupted, a new team that chose their own tool, and an acquisition that brought a third stack along for the ride. What emerged was an accidental living benchmark.
This post isn't a feature table copied from a vendor website. It's what we learned running Terraform (OpenTofu), Pulumi, and AWS CDK side by side in real production environments — the state corruption incidents, the testing frameworks that actually caught bugs, the drift detection that saved us, and the one thing all three get wrong.
| Dimension | Terraform / OpenTofu | Pulumi | AWS CDK |
|---|---|---|---|
| Language | HCL | TypeScript, Python, Go, C#, Java, YAML | TypeScript, Python, Go, Java, C#, .NET |
| State | Remote backends (S3, Terraform Cloud, etc.) | Managed (Pulumi Cloud) or self-managed | CloudFormation (AWS-managed) |
| Testing | Terratest, OPA, Sentinel | Built-in unit/integration test framework | CDK Assertions, integ-runner |
| Multi-cloud | Excellent | Excellent | AWS-only |
| Drift detection | Manual or Terraform Cloud | Manual (Pulumi Cloud has preview) | AWS Config + Drift (CloudFormation) |
| Learning curve | Medium (new language) | Medium-High (SDK complexity) | Medium (CloudFormation knowledge required) |
The State Management Reality: Nobody Gets It Perfect
State management is the single biggest differentiator between these tools, and the single biggest source of production incidents. Here's what three years of state corruption taught us.
Terraform State: Powerful but Fragile
Terraform's state file is a JSON blob that maps real infrastructure to your config. It's the source of truth, and it breaks in spectacular ways.
# terraform state — the power and the danger
terraform {
backend "s3" {
bucket = "my-company-tfstate"
key = "prod/network/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-state-locks"
}
}
This looks simple. The reality is that we've had:
- State lock timeout during long
apply— DynamoDB lock expires, a second CI pipeline starts, and now two processes are mutating the same state. Corrupt. - Partial state write on SIGKILL — CI runner killed mid-
apply$, the state file is half-written with null resource addresses. Console-only fix. - Manual state surgery gone wrong — Someone terraform state rm
'd a resource they shouldn't have. Three weeks to untangle.
The fix we landed on: immutable state histories with before/after snapshots. Every apply triggers a Lambda that copies the state file to a versioned path before and after:
#!/usr/bin/env bash
# terraform-apply-safe.sh — our state safety wrapper
set -euo pipefail
STATE_BUCKET="my-company-tfstate"
STATE_KEY="prod/network/terraform.tfstate"
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
SNAPSHOT_PATH="snapshots/$TIMESTAMP"
echo "=== Capturing pre-apply state snapshot ==="
aws s3 cp "s3://$STATE_BUCKET/$STATE_KEY" "s3://$STATE_BUCKET/$SNAPSHOT_PATH/pre-apply.json"
echo "=== Running terraform apply ==="
terraform apply -auto-approve
echo "=== Capturing post-apply state snapshot ==="
aws s3 cp "s3://$STATE_BUCKET/$STATE_KEY" "s3://$STATE_BUCKET/$SNAPSHOT_PATH/post-apply.json"
echo "=== Diffing state changes ==="
aws s3 cp "s3://$STATE_BUCKET/$SNAPSHOT_PATH/pre-apply.json" /tmp/pre.json
aws s3 cp "s3://$STATE_BUCKET/$SNAPSHOT_PATH/post-apply.json" /tmp/post.json
jq -r '.resources[].type + " " + .resources[].name' /tmp/pre.json | sort > /tmp/pre-resources.txt
jq -r '.resources[].type + " " + .resources[].name' /tmp/post.json | sort > /tmp/post-resources.txt
diff /tmp/pre-resources.txt /tmp/post-resources.txt || true
This saved us three times in two years. When state goes bad, you need a before-image. Terraform doesn't give you one out of the box.
Pulumi State: Managed, But At What Cost?
Pulumi's managed state backend is genuinely better than DIY Terraform state — until it isn't. The service handles locking, versioning, and history automatically. But you're trusting a third-party control plane for your entire infrastructure.
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// Pulumi's state is managed — you don't deal with backends
// But you DO deal with stack references and config secrets
const config = new pulumi.Config();
const vpcId = config.require("vpcId");
const cluster = new aws.ecs.Cluster("app-cluster", {
name: `app-cluster-${pulumi.getStack()}`,
settings: [{ name: "containerInsights", value: "enabled" }],
});
The managed state is great until:
- Pulumi Cloud has an outage — happened twice. No preview
, noup$, norefresh$. You're blocked until they recover. - Self-managed state is an afterthought — You can use S3 + encryption, but you lose the web UI, resource search, and policy enforcement. The self-managed path feels second-class.
- State secrets — Pulumi encrypts config secrets at rest, but the plaintext is visible in pulumi config
output if you don't configure encryption properly. We caught a developer committing an unencrypted database password twice.
The verdict: managed state is better than DIY Terraform state for most teams, but the dependency on Pulumi Cloud creates risk that enterprise teams should quantify before committing.
CDK State: CloudFormation Is The Unsung Hero
CDK synthesises to CloudFormation templates, and CloudFormation manages state server-side. You never touch a state file. You never deal with lock contention. You never manually edit a JSON blob to fix a corrupted resource address.
import * as cdk from "aws-cdk-lib";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
export class AppStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const vpc = ec2.Vpc.fromLookup(this, "Vpc", { isDefault: false });
const cluster = new ecs.Cluster(this, "AppCluster", {
vpc,
containerInsights: true,
});
}
}
CloudFormation's managed state is the best of the three — until your stack enters UPDATE_ROLLBACK_FAILED and you're manually deleting nonce resources via the console at 2 AM.
The key tradeoff: CloudFormation manages state flawlessly 99% of the time, but when it fails, the recovery path involves clicking through the AWS Console or writing a Python script against the CloudFormation API. There's no terraform state rm equivalent.
The Language Wars: HCL vs Real Languages vs DSLs
HCL Is Not the Problem
The conventional wisdom says "HCL is bad because it's not a real programming language." I disagree. HCL's limitations are features for infrastructure:
# HCL's simplicity prevents foot-guns
resource "aws_lb_target_group" "app" {
name = "app-tg-${var.environment}"
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = "/health"
interval = 30
healthy_threshold = 2
unhealthy_threshold = 3
}
tags = var.tags
}
``%
The lack of loops, conditionals, and inheritance means you can't write clever infrastructure. And that's the point. Clever infrastructure is fragile infrastructure. HCL forces you to be explicit.
Where HCL actually fails:
1. **No proper module testing** — `terraform validate` only checks syntax and type constraints. It doesn't test that your module produces the right resources.
2. **Reusability without composability** — Modules are black boxes. You can't override a single property deep in a module without forking the whole thing.
3. **Variable propagation hell** — A VPC module that takes 47 variables because every submodule needs its own inputs.
### Pulumi's Real Language Advantage
Pulumi's TypeScript support lets you use loops, conditionals, and functions naturally:typescript
// Pulumi: real language features for infrastructure
import * as aws from "@pulumi/aws";
interface ServiceConfig {
name: string;
port: number;
healthCheckPath: string;
desiredCount: number;
}
const services: ServiceConfig[] = [
{ name: "api", port: 3000, healthCheckPath: "/health", desiredCount: 3 },
{ name: "admin", port: 3001, healthCheckPath: "/admin/health", desiredCount: 2 },
{ name: "worker", port: 0, healthCheckPath: "/worker/health", desiredCount: 5 },
];
const targetGroups = services.map((svc) => {
const tg = new aws.lb.TargetGroup(tg-${svc.name}, {
port: svc.port || 80,
protocol: "HTTP",
healthCheck: { path: svc.healthCheckPath },
});
const service = new aws.ecs.Service(svc-${svc.name}, {
name: svc.name,
desiredCount: svc.desiredCount,
// ... other config
});
return { tg, service };
});
This is genuinely better than HCL for complex infrastructure. But it introduces new problems:
1. **Asynchronous resource references** — `pulumi.Output<string>` types propagate through your entire codebase. You can't just concatenate two output strings; you need `pulumi.all([a, b]).apply()`. Every team member needs to understand monads.
2. **Constructing `awsx` complexity** — Pulumi's crosswalk libraries try to abstract complexity but often obscure what CloudFormation API calls they're making underneath.
3. **Import drift** — Real languages let you import libraries that do arbitrary things during preview. We caught a library that was making HTTP calls during `pulumi preview` to fetch secret values by name.
### CDK: The Framework That Understands AWS
CDK's real advantage isn't TypeScript — it's that the library authors understand AWS deeply. An L2 construct like `ecs.Cluster` handles the CloudFormation resource wiring that you'd write manually in Terraform:
| Aspect | Terraform | Pulumi | CDK |
|---|---|---|---|
| ECS cluster + service + task def | ~150 lines HCL, 3 resources, service-linked roles manual | ~80 lines, automation via crosswalk | ~40 lines, L2 constructs handle IAM and logging |
| VPC with public/private subnets | ~60 lines, need to create subnets + route tables + NAT + IGW explicitly | ~50 lines, similar explicitness | ~15 lines, `ec2.Vpc` with default config |
| Custom resource to run a script | `null_resource` + local-exec (unreliable) | `pulumi.command.local` | `cr.Logic` or custom resource Lambda |
The downside: CDK only knows AWS. If you need to manage a Cloudflare DNS record, a Datadog monitor, and a GitHub Actions runner alongside your AWS infrastructure, you're either mixing tools or building custom providers.
## Testing Infrastructure: The Gap All Three Share
Here's the uncomfortable truth: **none of these tools have great testing**. Infrastructure testing is the frontier that all three are still figuring out.
### Terraform Testinghcl
# Terraform 1.6+ has a test framework. Let's see how it works.
terraform {
tests {
# This is new in 1.6 — not widely adopted yet
}
}
# The old way: Terratest (Go)
# test/main_test.go
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestVPCModule(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{
"environment": "test",
"cidr_block": "10.0.0.0/16",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Assert outputs are as expected
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
assert.Regexp(t, "^vpc-", vpcId)
}
Terratest works but is slow — each test provisions real infrastructure, takes minutes, and costs money. Most teams don't run it in CI. We ran it weekly on a schedule and caught exactly one regression in 18 months.
### Pulumi Testing
Pulumi's testing story is genuinely better:typescript
import * as pulumi from "@pulumi/pulumi";
import { assert } from "chai";
// Unit test: doesn't provision anything, just checks the Mocks
pulumi.runtime.setMocks({
newResource: function (type, name, inputs) {
return { id: ${name}_id, outs: inputs };
},
call: function (token, args, provider) {
return args;
},
});
describe("VPC Module", () => {
let infra: typeof import("../infra");
before(async () => {
process.env.PULUMI_TEST = "true";
infra = await import("../infra");
});
it("creates a VPC with DNS support enabled", () => {
const vpc = (infra as any).vpc;
assert.strictEqual(vpc.enableDnsSupport, true);
});
it("creates public subnets in all AZs", () => {
const subnetIds = (infra as any).publicSubnetIds;
assert.isAtLeast(subnetIds.length, 2);
});
});
The mock-based unit tests run in milliseconds and catch logical errors (wrong subnet count, missing tags, incorrect security group rules). They don't catch CloudFormation validation errors or IAM permission issues — those need integration tests.
### CDK Testing
CDK has the richest testing story with fine-grained assertion libraries:typescript
import { Template } from "aws-cdk-lib/assertions";
import { App } from "aws-cdk-lib";
import { AppStack } from "../lib/app-stack";
describe("AppStack", () => {
let template: Template;
beforeAll(() => {
const app = new App();
const stack = new AppStack(app, "TestStack", {
env: { account: "123456789012", region: "eu-west-1" },
});
template = Template.fromStack(stack);
});
test("creates an ECS cluster with container insights", () => {
template.hasResourceProperties("AWS::ECS::Cluster", {
ClusterSettings: [
{ Name: "containerInsights", Value: "enabled" },
],
});
});
test("ALB is internet-facing only in prod", () => {
template.hasResourceProperties("AWS::ElasticLoadBalancingV2::LoadBalancer", {
Scheme: "internet-facing",
});
});
test("all security groups restrict ingress", () => {
template.resourceCountIs("AWS::EC2::SecurityGroup", 2);
// Check no security group has 0.0.0.0/0 on non-HTTP ports
const groups = template.findResources("AWS::EC2::SecurityGroup");
Object.values(groups).forEach((group: any) => {
const rules = group.SecurityGroupIngress || [];
rules.forEach((rule: any) => {
if (rule.CidrIp === "0.0.0.0/0") {
expect(rule.FromPort).toBeGreaterThanOrEqual(80);
expect(rule.ToPort).toBeLessThanOrEqual(443);
}
});
});
});
});
These tests are fast, deterministic, and run in CI without provisioning anything. They caught:
- A security group that accidentally exposed port 22 to the world
- A task definition that referenced a non-existent log group
- A load balancer listener that forwarded to a deleted target group
**CDK wins on testing, hands down.** It's the only one where testing is built into the framework rather than bolted on.
## Migration Stories: Moving Between Tools
If you're reading this and thinking about switching, here's what our migrations taught us:
### Terraform → Pulumi
The `pulumi import` command works well for resources with unique identifiers:bash
# Import existing infrastructure into Pulumi
pulumi import aws:ecs/cluster:Cluster app-cluster arn:aws:ecs:eu-west-1:123456789012:cluster/app-cluster-prod
pulumi import aws:ecs/service:Service app-service arn:aws:ecs:eu-west-1:123456789012:service/app-cluster-prod/app-service
%
The issue: Pulumi's resource type naming doesn't always match Terraform's. An aws_s3_bucket with force_destroy becomes a Pulumi aws.s3.BucketV2 with forceDestroy. You'll spend days reconciling the mapping for non-trivial state.
Terraform → CDK
You can't import CloudFormation state directly. The migration is: create the CDK stack, run cdk deploy (which creates new resources), then cut traffic over. Or you use the cdk import command to bring existing resources under CloudFormation management:
`bash
# First, write CFN template matching existing resources
# Then import them
cdk import --app "npx ts-node bin/app.ts"
%
CDK import is powerful but brittle — the template must match exactly. If your existing ECS service has a healthCheckGracePeriodSeconds` that defaults to 0 but isn't explicit in your CDK code, the import fails with a cryptic error about "desired values not matching."
The Verdict: Pick Based On Your Team, Not The Hype
After three years of running all three, here's the decision framework we use:
Choose Terraform (or OpenTofu) when:
- You need multi-cloud (AWS + GCP + Azure)
- Your team has existing HCL knowledge
- You want the most battle-tested tool with the largest community
- You're comfortable managing state infrastructure yourself
Choose Pulumi when:
- Your team is primarily software engineers who know TypeScript/Python
- You need multi-cloud but want a single language
- You're building complex, parameterized infrastructure that benefits from loops and conditionals
- You accept the dependency on Pulumi Cloud (or the self-managed tradeoffs)
Choose CDK when:
- You're all-in on AWS and will stay there
- Testing infrastructure is a priority
- You want the best developer experience with IDE autocomplete and type safety
- You want managed state without running your own backend
Pick one. Commit to it. Build expertise. That investment pays off more than any tool-specific feature.
If you're starting fresh today with a greenfield AWS-only project on a team of TypeScript developers who value testing? CDK. If you're building a multi-cloud platform with a DevOps team that's seen everything? OpenTofu. If you're a startup that needs to move fast with familiar languages and doesn't mind vendor dependency? Pulumi.
There's no wrong choice between these three. There's only the wrong choice for your team.