Infrastructure as Code: Terraform vs Pulumi vs CloudFormation vs CDK
I've used all four of these tools in production. Terraform for BirJob's infrastructure, CloudFormation at a previous job, Pulumi for a side project, and AWS CDK for a client engagement. Each has left scars and each has earned my respect. The "best" one depends entirely on your team, your cloud provider mix, and how much you value type safety vs ecosystem breadth.
Most comparison articles list features in a table and call it a day. This guide goes deeper: I'll show you real code for the same infrastructure in all four tools, discuss the operational differences you'll feel daily, and give you a framework for making the decision.
The Same Infrastructure, Four Ways
Let's provision a simple but realistic setup: a VPC, a PostgreSQL database, an ECS service, and an Application Load Balancer. This represents the kind of infrastructure most web applications need.
Terraform (HCL)
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "birjob-tf-state"
key = "prod/terraform.tfstate"
region = "eu-west-1"
}
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "birjob-vpc" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "birjob-private-${count.index}" }
}
resource "aws_db_instance" "postgres" {
identifier = "birjob-db"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.t3.medium"
allocated_storage = 50
db_name = "birjob"
username = var.db_username
password = var.db_password
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
skip_final_snapshot = false
lifecycle {
prevent_destroy = true
}
}
resource "aws_ecs_service" "api" {
name = "birjob-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = 2
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.ecs.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = 3000
}
}
Pulumi (TypeScript)
// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const vpc = new aws.ec2.Vpc("birjob-vpc", {
cidrBlock: "10.0.0.0/16",
enableDnsHostnames: true,
tags: { Name: "birjob-vpc" },
});
const privateSubnets = [0, 1].map(i =>
new aws.ec2.Subnet(`birjob-private-${i}`, {
vpcId: vpc.id,
cidrBlock: `10.0.${i}.0/24`,
availabilityZone: aws.getAvailabilityZones().then(azs => azs.names[i]),
tags: { Name: `birjob-private-${i}` },
})
);
const db = new aws.rds.Instance("birjob-db", {
identifier: "birjob-db",
engine: "postgres",
engineVersion: "16.1",
instanceClass: "db.t3.medium",
allocatedStorage: 50,
dbName: "birjob",
username: config.requireSecret("dbUsername"),
password: config.requireSecret("dbPassword"),
vpcSecurityGroupIds: [dbSg.id],
dbSubnetGroupName: subnetGroup.name,
skipFinalSnapshot: false,
}, { protect: true });
const apiService = new aws.ecs.Service("birjob-api", {
name: "birjob-api",
cluster: cluster.arn,
taskDefinition: taskDef.arn,
desiredCount: 2,
launchType: "FARGATE",
networkConfiguration: {
subnets: privateSubnets.map(s => s.id),
securityGroups: [ecsSg.id],
},
loadBalancers: [{
targetGroupArn: targetGroup.arn,
containerName: "api",
containerPort: 3000,
}],
});
export const dbEndpoint = db.endpoint;
export const albDnsName = alb.dnsName;
AWS CloudFormation (YAML)
AWSTemplateFormatVersion: '2010-09-09'
Description: BirJob Infrastructure
Parameters:
DbUsername:
Type: String
NoEcho: true
DbPassword:
Type: String
NoEcho: true
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsHostnames: true
Tags:
- Key: Name
Value: birjob-vpc
PrivateSubnet0:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.0.0/24
AvailabilityZone: !Select [0, !GetAZs '']
Tags:
- Key: Name
Value: birjob-private-0
Database:
Type: AWS::RDS::DBInstance
DeletionPolicy: Retain
Properties:
DBInstanceIdentifier: birjob-db
Engine: postgres
EngineVersion: '16.1'
DBInstanceClass: db.t3.medium
AllocatedStorage: 50
DBName: birjob
MasterUsername: !Ref DbUsername
MasterUserPassword: !Ref DbPassword
VPCSecurityGroups:
- !GetAtt DbSecurityGroup.GroupId
DBSubnetGroupName: !Ref SubnetGroup
ApiService:
Type: AWS::ECS::Service
Properties:
ServiceName: birjob-api
Cluster: !Ref Cluster
TaskDefinition: !Ref TaskDefinition
DesiredCount: 2
LaunchType: FARGATE
NetworkConfiguration:
AwsvpcConfiguration:
Subnets:
- !Ref PrivateSubnet0
- !Ref PrivateSubnet1
SecurityGroups:
- !GetAtt EcsSecurityGroup.GroupId
LoadBalancers:
- TargetGroupArn: !Ref TargetGroup
ContainerName: api
ContainerPort: 3000
Outputs:
DatabaseEndpoint:
Value: !GetAtt Database.Endpoint.Address
AWS CDK (TypeScript)
// lib/birjob-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
export class BirjobStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const vpc = new ec2.Vpc(this, 'BirjobVpc', {
maxAzs: 2,
natGateways: 1,
});
const db = new rds.DatabaseInstance(this, 'BirjobDb', {
engine: rds.DatabaseInstanceEngine.postgres({
version: rds.PostgresEngineVersion.VER_16_1,
}),
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM),
vpc,
databaseName: 'birjob',
allocatedStorage: 50,
removalPolicy: cdk.RemovalPolicy.RETAIN,
credentials: rds.Credentials.fromGeneratedSecret('birjob_admin'),
});
const cluster = new ecs.Cluster(this, 'BirjobCluster', { vpc });
const fargateService = new ecs.FargateService(this, 'BirjobApi', {
cluster,
taskDefinition: taskDef,
desiredCount: 2,
});
const alb = new elbv2.ApplicationLoadBalancer(this, 'BirjobAlb', {
vpc,
internetFacing: true,
});
const listener = alb.addListener('HttpListener', { port: 80 });
listener.addTargets('ApiTarget', {
port: 3000,
targets: [fargateService],
healthCheck: { path: '/health' },
});
new cdk.CfnOutput(this, 'AlbDnsName', { value: alb.loadBalancerDnsName });
}
}
Deep Comparison
| Criteria | Terraform | Pulumi | CloudFormation | CDK |
|---|---|---|---|---|
| Language | HCL (domain-specific) | TypeScript, Python, Go, C#, Java | YAML/JSON | TypeScript, Python, Java, C#, Go |
| Cloud support | Multi-cloud (3000+ providers) | Multi-cloud (100+ providers) | AWS only | AWS only (mostly) |
| State management | Self-managed (S3, Terraform Cloud) | Pulumi Cloud or self-managed | AWS-managed | CloudFormation stack |
| Type safety | Limited (validation blocks) | Full (IDE autocomplete, compile errors) | None (linting only) | Full (TypeScript/Java types) |
| Loops and conditions | count, for_each, dynamic blocks | Native language constructs | Conditions, !If, transforms | Native language constructs |
| Testing | Terratest (Go), built-in checks | Unit tests in your language | cfn-lint, TaskCat | CDK Assertions, jest |
| Ecosystem | Largest (Registry, modules) | Growing | AWS examples, StackSets | AWS Construct Hub |
| Learning curve | Learn HCL (1-2 weeks) | Use existing language | Learn YAML quirks (painful) | Use existing language + learn constructs |
| Drift detection | terraform plan | pulumi preview | Drift detection (recent) | Via CloudFormation |
| Import existing | terraform import | pulumi import | Resource import | Via CloudFormation |
| Pricing | Free (OSS), Terraform Cloud paid | Free (OSS), Pulumi Cloud paid | Free (AWS service) | Free (OSS) |
Strengths and Weaknesses
Terraform
Strengths: Largest provider ecosystem (AWS, GCP, Azure, Cloudflare, GitHub, Datadog, PagerDuty — everything). Battle-tested at massive scale. Excellent module registry. HCL is readable by non-developers. The plan command shows exactly what will change before applying.
Weaknesses: HCL has limited expressiveness — complex logic requires workarounds. State management requires setup. No real programming constructs (no functions, limited loops). According to HashiCorp's State of Cloud report, state management is the top challenge cited by Terraform users.
Pulumi
Strengths: Use real programming languages — TypeScript, Python, Go, C#, Java. Full IDE support (autocomplete, type checking, refactoring). Easy to create abstractions and share as packages. Unit testing with standard test frameworks.
Weaknesses: Smaller provider ecosystem than Terraform. Newer community means fewer examples and Stack Overflow answers. Pulumi Cloud is required for team features (or you self-host). The Pulumi documentation is good but not as extensive as Terraform's.
CloudFormation
Strengths: Native AWS integration — no state file to manage, instant support for new AWS services, StackSets for multi-account deployments. Free to use. Deeply integrated with AWS Organizations and Service Catalog.
Weaknesses: YAML/JSON configuration is verbose and error-prone. AWS-only. Slow deployments (waits for each resource). Error messages are cryptic. Rollback on failure can get stuck. No loops (only Conditions and Transforms).
AWS CDK
Strengths: Real programming languages on top of CloudFormation. High-level constructs that create multiple resources with good defaults. The Construct Hub has community patterns. Excellent for teams already deep in AWS.
Weaknesses: Compiles to CloudFormation (inherits its limitations). AWS-only. Construct versions can break between updates. Debugging requires understanding both CDK and CloudFormation layers.
Decision Framework
Do you use multiple cloud providers?
├── Yes → Terraform or Pulumi
│ ├── Team prefers declarative/DSL → Terraform
│ └── Team prefers real programming languages → Pulumi
└── No (AWS only)
├── Need simple, native integration → CloudFormation
├── Want programming languages + AWS → CDK
└── Want multi-cloud option for future → Terraform
My Recommendation by Team Profile
| Team Profile | Recommendation | Why |
|---|---|---|
| Small startup, AWS-only | CDK (TypeScript) | Fast to start, great defaults, familiar language |
| Small startup, multi-cloud | Terraform | Biggest ecosystem, most hiring pool |
| Mid-size engineering team | Terraform or Pulumi | Team preference on language vs DSL |
| Enterprise, AWS-native | CDK + CloudFormation | StackSets, Service Catalog, Organizations |
| Enterprise, multi-cloud | Terraform | De facto standard, most mature tooling |
| Team of backend devs (Python/TS) | Pulumi | No new language to learn |
My Opinionated Take
1. Terraform is still the safe default. It has the largest community, the most providers, and the most hiring demand. If you learn one IaC tool, learn Terraform. You can always specialize later.
2. Pulumi is the future for developer-led infrastructure. As more application developers take responsibility for infrastructure (the platform engineering trend), tools that use real programming languages will win. Pulumi's approach is more natural for developers.
3. CloudFormation is fine, but don't start new projects with raw YAML. If you're in the AWS ecosystem, use CDK instead of raw CloudFormation. The developer experience difference is night and day.
4. CDK is underrated. The L2 and L3 constructs encode AWS best practices that you'd otherwise have to research yourself. A single CDK construct can replace 100+ lines of Terraform. For AWS-only shops, CDK is often the most productive choice.
5. Don't mix tools. Using Terraform for networking and CDK for compute sounds flexible but creates operational nightmares. Pick one tool and commit to it for your entire infrastructure.
Action Plan
Week 1: Evaluate
- Assess your cloud provider mix (single-cloud vs multi-cloud)
- Survey your team's language preferences
- Try the getting-started tutorial for 2-3 tools
- Provision the same simple resource (an S3 bucket) in each tool
Week 2: Prototype
- Pick your top choice
- Provision a realistic staging environment
- Set up CI/CD pipeline for the IaC
- Import any existing resources
Week 3+: Production
- Migrate production infrastructure incrementally
- Set up state management and locking
- Write modules/constructs for common patterns
- Document conventions for the team
Sources
- Terraform Registry
- Pulumi Documentation
- AWS CloudFormation Documentation
- AWS CDK Documentation
- CDK Construct Hub
- HashiCorp: State of Cloud Strategy
I'm Ismat, and I build BirJob — Azerbaijan's job aggregator scraping 80+ sources daily.
