Infrastructure as Code: Terraform vs Pulumi vs CloudFormation
Infrastructure as Code: Terraform vs Pulumi vs CloudFormation#
Clicking buttons in the AWS console doesn't scale. Infrastructure as Code (IaC) lets you define, version, and reproduce your entire cloud environment in files — just like application code.
Why Infrastructure as Code?#
| Manual (Console) | Infrastructure as Code |
|---|---|
| Click buttons, hope you remember what you did | Declarative files in Git |
| "It works on my AWS account" | Reproducible across environments |
| No audit trail | Git history = audit trail |
| Snowflake servers | Identical staging + production |
| One person knows the setup | Anyone can read the code |
The Big Three (+ CDK)#
Terraform (HashiCorp)#
Declarative HCL. The industry standard for multi-cloud.
resource "aws_instance" "api" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = { Name = "api-server" }
}
resource "aws_rds_instance" "db" {
engine = "postgres"
instance_class = "db.t3.micro"
allocated_storage = 20
}
Pros: Multi-cloud, massive provider ecosystem (3000+), module registry, mature Cons: HCL is its own language, state management complexity
Pulumi#
Real programming languages (TypeScript, Python, Go, C#, Java).
const server = new aws.ec2.Instance("api", {
ami: "ami-0c55b159cbfafe1f0",
instanceType: "t3.micro",
tags: { Name: "api-server" },
});
const db = new aws.rds.Instance("db", {
engine: "postgres",
instanceClass: "db.t3.micro",
allocatedStorage: 20,
});
Pros: Use your language (loops, conditions, abstractions), type safety, testing with real test frameworks Cons: Smaller ecosystem than Terraform, newer
CloudFormation (AWS)#
AWS-native. JSON/YAML templates, deep AWS integration.
Resources:
ApiServer:
Type: AWS::EC2::Instance
Properties:
ImageId: ami-0c55b159cbfafe1f0
InstanceType: t3.micro
Pros: AWS-native (always supports latest services first), no state file to manage Cons: AWS-only, verbose YAML/JSON, slow rollbacks
AWS CDK#
CloudFormation via code. Write TypeScript/Python, generates CloudFormation.
const vpc = new ec2.Vpc(this, "VPC", { maxAzs: 2 });
const cluster = new ecs.Cluster(this, "Cluster", { vpc });
const service = new ecs_patterns.ApplicationLoadBalancedFargateService(
this, "Service", { cluster, taskImageOptions: { image: ecs.ContainerImage.fromRegistry("nginx") } }
);
Pros: High-level constructs (L2/L3), generates hundreds of resources from 10 lines Cons: AWS-only, another abstraction layer
Comparison#
| Feature | Terraform | Pulumi | CloudFormation | CDK |
|---|---|---|---|---|
| Language | HCL | TS/Python/Go/C# | JSON/YAML | TS/Python/Go |
| Multi-cloud | Yes | Yes | AWS only | AWS only |
| State | Remote (S3, Terraform Cloud) | Pulumi Cloud or self-managed | AWS-managed | AWS-managed |
| Ecosystem | 3000+ providers | Growing | AWS only | AWS only |
| Learning curve | Medium (new language) | Low (your language) | Medium (verbose) | Low-Medium |
| Testing | Terratest, OPA | Native test frameworks | Taskcat | CDK assertions |
| Best for | Multi-cloud, large teams | Dev teams, TypeScript shops | AWS-native orgs | AWS + want code |
State Management#
IaC tools track what resources exist in a state file. This is the source of truth for what's deployed.
Terraform State#
terraform.tfstate → JSON file mapping your code to real cloud resources
Where to store it:
- S3 + DynamoDB (locking) — most common
- Terraform Cloud — managed state with collaboration
- Never local — state contains secrets, must be shared and locked
terraform {
backend "s3" {
bucket = "myapp-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
State Pitfalls#
- State drift — someone changes infra manually → state doesn't match reality
- State corruption — interrupted apply → partial state
- Secrets in state — database passwords are stored in plaintext (encrypt at rest!)
Module Patterns#
Reusable Module#
# modules/api-service/main.tf
variable "name" {}
variable "image" {}
variable "cpu" { default = 256 }
variable "memory" { default = 512 }
resource "aws_ecs_service" "this" {
name = var.name
task_definition = aws_ecs_task_definition.this.arn
desired_count = 2
}
# environments/prod/main.tf
module "api" {
source = "../../modules/api-service"
name = "api"
image = "myapp/api:v2"
cpu = 512
memory = 1024
}
module "worker" {
source = "../../modules/api-service"
name = "worker"
image = "myapp/worker:v2"
}
Environment Pattern#
infrastructure/
├── modules/
│ ├── networking/
│ ├── database/
│ ├── api-service/
│ └── monitoring/
├── environments/
│ ├── dev/
│ │ └── main.tf (small instances, single AZ)
│ ├── staging/
│ │ └── main.tf (mirrors prod, smaller scale)
│ └── prod/
│ └── main.tf (multi-AZ, auto-scaling)
└── global/
└── main.tf (IAM, DNS, shared resources)
Best Practices#
- Remote state with locking — always, no exceptions
- Plan before apply — review every change before deploying
- Small, focused modules — one module per logical component
- Environment parity — same modules for dev/staging/prod, different variables
- No manual changes — if it's not in code, it doesn't exist
- Secrets in Vault — never hardcode credentials in IaC files
- CI/CD for infra —
terraform planon PR,terraform applyon merge - Drift detection — regularly compare state to actual infrastructure
IaC in CI/CD#
# .github/workflows/terraform.yml
on:
pull_request:
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
jobs:
plan:
if: github.event_name == 'pull_request'
steps:
- run: terraform init
- run: terraform plan -out=plan.tfplan
- run: terraform show -json plan.tfplan > plan.json
# Post plan as PR comment
apply:
if: github.ref == 'refs/heads/main'
steps:
- run: terraform init
- run: terraform apply -auto-approve
When to Use Which#
| Scenario | Recommendation |
|---|---|
| Multi-cloud (AWS + GCP) | Terraform |
| TypeScript team, AWS | Pulumi or CDK |
| AWS-only, large org | CloudFormation or CDK |
| Startup, move fast | Pulumi (your language) |
| Enterprise, compliance | Terraform + Sentinel policies |
| Simple setup (< 20 resources) | Any — or even Railway/Render |
Codelit generates both Terraform (AWS) and Pulumi (GCP) exports from any architecture diagram — try it at codelit.io.
Summary#
- Always use IaC — no exceptions for production infrastructure
- Terraform is the safe default — largest ecosystem, multi-cloud
- Pulumi if you prefer real languages over HCL
- CDK if you're AWS-only and want high-level abstractions
- Remote state + locking is non-negotiable
- Treat infra like code — PRs, reviews, CI/CD, tests
Export Terraform and Pulumi from any architecture at codelit.io — 29 export formats including AWS, GCP, K8s, Docker, and monitoring configs.
Try it on Codelit
Cost Estimator
See estimated AWS monthly costs for every component in your architecture
GitHub Integration
Paste a repo URL and generate architecture from your actual codebase
Related articles
Try these templates
Build this architecture
Generate an interactive architecture for Infrastructure as Code in seconds.
Try it in Codelit →
Comments