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.
Try these templates
Continue learning
Go deeper on infrastructure and delivery
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
The DevOps Handbook
Gene Kim, Jez Humble · 2021
Case studies from Etsy, Amazon, and Target on rewiring deployment pipelines and org structure.
Observability Engineering
Charity Majors, Liz Fong-Jones · 2026
2026 rewrite covering OpenTelemetry rollout, telemetry cost control, and tracing LLM calls.
#11 in Software Design ToolsKubernetes Patterns
Bilgin Ibryam, Roland Huss · 2023
A catalogue of named patterns, sidecars to operators to init containers, with runnable manifests.
Building Microservices
Sam Newman · 2021
600 pages on splitting a monolith: service boundaries, data decomposition, migration order.