If you’re deploying cloud infrastructure at scale, you need to automate it. That’s where Infrastructure as Code (IaC) comes in. But with several mature tools—Terraform, Pulumi, and AWS CloudFormation—which one should you use? This guide compares all three by deploying the same AWS stack (VPC, EC2, RDS) with each. You’ll see the real syntax, state management tradeoffs, and what the learning curve looks like when applying these tools to production workloads.
Key Takeaways:
- See practical examples of deploying a VPC, EC2, and RDS instance with Terraform, Pulumi, and CloudFormation
- Understand the real syntax, workflow, and state management differences between the tools
- Evaluate the learning curve and team impact for each approach
- Spot common mistakes and find best practices for IaC in production
- Decide which IaC tool fits your stack, team, and operational requirements
Overview of IaC Tools: Terraform, Pulumi, CloudFormation
Infrastructure as Code lets you define, provision, and manage cloud resources using code. The three major players for AWS infrastructure are:
| Tool | Language | Multi-Cloud | State Management | Community/Support |
|---|---|---|---|---|
| Terraform | HCL (HashiCorp Config Language) | Yes | Local/Remote (backend) | Very Active |
| Pulumi | General-purpose (TypeScript, Python, Go, etc.) | Yes | Local/Cloud (Pulumi Service or S3, etc.) | Active, smaller than Terraform |
| CloudFormation | YAML/JSON | No (AWS only) | Managed by AWS | Native AWS support |
All three tools let you codify infrastructure, run diffs/plans, and automate deployment. But their syntax, workflow, and extensibility differ sharply. If you’re running Kubernetes, you may also want to check out GitOps with ArgoCD for Kubernetes-native IaC approaches.
Real-World Deployment: VPC + EC2 + RDS with Each Tool
Let’s deploy a minimal but production-relevant AWS setup:
- One VPC (10.0.0.0/16)
- Public subnet
- EC2 instance (Amazon Linux 2)
- RDS instance (PostgreSQL, single-AZ, public access disabled)
Terraform Example (HCL)
# main.tf
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = { Name = "iac-demo-vpc" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
availability_zone = "us-east-1a"
}
resource "aws_security_group" "ec2_sg" {
vpc_id = aws_vpc.main.id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "ec2-sg" }
}
resource "aws_instance" "web" {
ami = "ami-0c02fb55956c7d316" # Amazon Linux 2 (check for updates)
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.ec2_sg.id]
tags = { Name = "iac-demo-ec2" }
}
resource "aws_db_subnet_group" "default" {
name = "iac-demo-db-subnet"
subnet_ids = [aws_subnet.public.id]
}
resource "aws_db_instance" "postgres" {
allocated_storage = 20
engine = "postgres"
engine_version = "14.7"
instance_class = "db.t3.micro"
db_subnet_group_name = aws_db_subnet_group.default.name
vpc_security_group_ids = [aws_security_group.ec2_sg.id]
username = "masteruser"
password = "CHANGEME123!" # Use secrets in production!
skip_final_snapshot = true
publicly_accessible = false
tags = { Name = "iac-demo-rds" }
}
Commands:
# Initialize and apply
terraform init
terraform plan
terraform apply
This HCL code is straightforward. Resources are strongly typed and dependencies are explicit. You need to configure remote state for production use (see later section).
Pulumi Example (TypeScript)
// index.ts
import * as aws from "@pulumi/aws";
const vpc = new aws.ec2.Vpc("iac-demo-vpc", {
cidrBlock: "10.0.0.0/16",
tags: { Name: "iac-demo-vpc" },
});
const subnet = new aws.ec2.Subnet("public", {
vpcId: vpc.id,
cidrBlock: "10.0.1.0/24",
availabilityZone: "us-east-1a",
mapPublicIpOnLaunch: true,
});
const ec2Sg = new aws.ec2.SecurityGroup("ec2-sg", {
vpcId: vpc.id,
ingress: [{
protocol: "tcp",
fromPort: 22,
toPort: 22,
cidrBlocks: ["0.0.0.0/0"],
}],
egress: [{
protocol: "-1",
fromPort: 0,
toPort: 0,
cidrBlocks: ["0.0.0.0/0"],
}],
tags: { Name: "ec2-sg" },
});
const ec2 = new aws.ec2.Instance("iac-demo-ec2", {
ami: "ami-0c02fb55956c7d316",
instanceType: "t3.micro",
subnetId: subnet.id,
vpcSecurityGroupIds: [ec2Sg.id],
tags: { Name: "iac-demo-ec2" },
});
const dbSubnetGroup = new aws.rds.SubnetGroup("db-subnet", {
subnetIds: [subnet.id],
tags: { Name: "iac-demo-db-subnet" }
});
const rds = new aws.rds.Instance("postgres", {
allocatedStorage: 20,
engine: "postgres",
engineVersion: "14.7",
instanceClass: "db.t3.micro",
dbSubnetGroupName: dbSubnetGroup.name,
vpcSecurityGroupIds: [ec2Sg.id],
username: "masteruser",
password: "CHANGEME123!", // Use Pulumi secrets for real
skipFinalSnapshot: true,
publiclyAccessible: false,
tags: { Name: "iac-demo-rds" },
});
Commands:
# Install dependencies
npm install @pulumi/pulumi @pulumi/aws
# Login to state backend (local or Pulumi Service)
pulumi login
# Preview and deploy
pulumi preview
pulumi up
Pulumi lets you use a familiar language (TypeScript here). You can leverage loops, conditionals, and code reuse like any app. Pulumi’s state can be managed locally or in their SaaS (with RBAC). For production, use Pulumi Service or an S3 backend with encryption and versioning enabled.
CloudFormation Example (YAML)
# vpc-ec2-rds.yaml
AWSTemplateFormatVersion: '2010-09-09'
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
Tags: [{ Key: Name, Value: "iac-demo-vpc" }]
PublicSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.1.0/24
AvailabilityZone: us-east-1a
MapPublicIpOnLaunch: true
EC2SecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: "Allow SSH"
VpcId: !Ref VPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: 0.0.0.0/0
SecurityGroupEgress:
- IpProtocol: -1
FromPort: 0
ToPort: 0
CidrIp: 0.0.0.0/0
Tags: [{ Key: Name, Value: "ec2-sg" }]
EC2Instance:
Type: AWS::EC2::Instance
Properties:
ImageId: ami-0c02fb55956c7d316
InstanceType: t3.micro
SubnetId: !Ref PublicSubnet
SecurityGroupIds: [!Ref EC2SecurityGroup]
Tags: [{ Key: Name, Value: "iac-demo-ec2" }]
DBSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: "iac-demo-db-subnet"
SubnetIds: [!Ref PublicSubnet]
Tags: [{ Key: Name, Value: "iac-demo-db-subnet" }]
RDSInstance:
Type: AWS::RDS::DBInstance
Properties:
AllocatedStorage: 20
DBInstanceClass: db.t3.micro
Engine: postgres
EngineVersion: "14.7"
MasterUsername: masteruser
MasterUserPassword: CHANGEME123!
DBSubnetGroupName: !Ref DBSubnetGroup
VPCSecurityGroups: [!Ref EC2SecurityGroup]
PubliclyAccessible: false
Tags: [{ Key: Name, Value: "iac-demo-rds" }]
Commands:
# Deploy via AWS CLI
aws cloudformation deploy --template-file vpc-ec2-rds.yaml --stack-name iac-demo-stack
CloudFormation uses declarative YAML. AWS manages the state and rollback, but syntax is verbose and less flexible for complex logic. Parameters and nested stacks can help, but there’s a steeper initial learning curve for anything nontrivial.
Syntax Comparison: HCL vs TypeScript vs YAML/JSON
The syntax you choose affects readability, maintainability, and reusability. Here’s how each stacks up for real teams:
| Aspect | Terraform (HCL) | Pulumi (TypeScript) | CloudFormation (YAML) |
|---|---|---|---|
| Imperative Logic | No (limited via modules) | Yes (full language) | No (conditions, intrinsic functions only) |
| Code Reuse | Modules, variables, outputs | Functions, classes, packages | Nested stacks, macros (advanced) |
| Readability | High for infra teams | High for dev teams | Verbose, but explicit |
| Drift Detection | terraform plan | pulumi preview | Change sets, drift detection tool |
Terraform is concise for infra-centric teams. Pulumi is ideal if you want to share IaC code with application developers or need advanced logic. CloudFormation is verbose but closest to AWS APIs and supports every new AWS feature first.
For advanced build workflows (e.g., Docker images), see advanced Docker multi-stage build strategies for production.
State Management and Change Tracking
How you store and track infrastructure state is critical for reliability and team safety. Each tool handles this differently:
Terraform State
- Default: local
terraform.tfstatefile (not suitable for teams) - Best practice: remote backend (S3 + DynamoDB for locking, or HashiCorp’s Terraform Cloud)
- Manual state locking, drift detection with
terraform plan
Pulumi State
- Default: local
Pulumi.stack.yamlfile - Best: Pulumi Service (SaaS) for RBAC, audit, and drift detection, or S3/GCS backend with encryption
- Easy to script state migrations or stack splitting
CloudFormation State
- Fully managed by AWS (no manual state files)
- Built-in rollback on failure, change sets for previews
- Detect drift via
aws cloudformation detect-stack-drift
For compliance and security, always encrypt state files and restrict access. Never store secrets in plain text (use environment variables or vault integrations).
| Tool | State Storage | Locking | Drift Detection | Secrets Handling |
|---|---|---|---|---|
| Terraform | Local or remote backend (S3, Terraform Cloud) | DynamoDB (S3 backend), built-in for Terraform Cloud | terraform plan, terraform state | Not encrypted by default, use Vault or environment variables |
| Pulumi | Local, Pulumi Service, S3, GCS, etc. | Pulumi Service has built-in locking | pulumi preview, pulumi stack | Encrypted with Pulumi secrets provider |
| CloudFormation | Managed by AWS | Automatic | Change sets, drift detection API | Parameters must be handled securely or via Secrets Manager |
For further reading on GitOps-based state management, see ArgoCD with GitOps as a complementary approach for Kubernetes deployments.
Learning Curve and Developer Experience
Your team’s existing skills and the complexity of your infrastructure should guide your IaC tool choice. Here’s what to expect:
- Terraform: Widely adopted, tons of examples. Learning HCL is easy for infra engineers, but advanced patterns (modules, workspaces, remote state) take time. Great docs and community support.
- Pulumi: If your team already works in TypeScript, Python, or Go, Pulumi feels natural. You can use real loops, conditionals, and test your infrastructure code like an application. However, debugging resource dependencies can be tricky if you’re used to declarative tools.
- CloudFormation: Steepest learning curve for complex stacks. YAML/JSON is verbose, and intrinsic functions are non-intuitive. But for pure AWS shops, it’s the most integrated and supports new AWS features first. Advanced use (macros, custom resources) requires deep AWS knowledge.
In production, Terraform and Pulumi win on flexibility and multi-cloud support. CloudFormation is the right call for AWS-only, compliance-heavy environments where managed state and rollback are non-negotiable.
| Tool | Onboarding Time | Best For | Gaps |
|---|---|---|---|
| Terraform | Low (simple usage), Medium (modules, remote state) | Infra/SRE teams, multi-cloud | Limited imperative logic, secrets handling |
| Pulumi | Medium (if new to IaC), Low (if devs know TypeScript/Python) | Dev teams, shared IaC/app logic | Smaller community, less mature for edge AWS features |
| CloudFormation | High (for complex stacks) | Pure AWS, regulated orgs | Verbosity, limited code reuse, slow for large stacks |
Common Pitfalls and Pro Tips
Common Mistakes
- Hardcoding secrets: Never put passwords or keys in your IaC files. Use environment variables, secrets managers, or encrypted state providers.
- Not enabling state locking in Terraform: This leads to race conditions and broken deployments. Always use S3 + DynamoDB or Terraform Cloud for team use.
- Ignoring resource dependencies: In Pulumi, you need to await or reference resource outputs explicitly. In CloudFormation, circular dependencies often cause stack failures.
- Forgetting resource cleanup: Failing to destroy stacks (especially RDS or EC2 resources) can lead to unexpected AWS bills. Always clean up test environments.
- Not using modules or reusable templates: Copy-pasting code multiplies maintenance pain. Use Terraform modules, Pulumi packages, or CloudFormation nested stacks.
Pro Tips
- For production, always version and review your IaC in source control (Git branches, pull requests).
- Automate linting and validation (e.g.,
terraform fmt,cfn-lint,pulumi lint) in CI/CD pipelines. - Set up automated drift detection jobs (e.g., nightly
terraform planoraws cloudformation detect-stack-drift). - When using Pulumi, take advantage of code reuse—write helper functions for common patterns (e.g., tagging, logging, monitoring).
- For CloudFormation, use AWS SAM or CDK for more complex scenarios—the CDK lets you write IaC in real languages, then synthesize to CloudFormation YAML.
Conclusion & Next Steps
If you want concise, cross-cloud infrastructure code, Terraform is the workhorse. If your team prefers imperative code and wants to integrate infrastructure and application logic, Pulumi is a strong contender. For AWS-only shops, CloudFormation offers the deepest integration and managed state—but at the cost of verbosity and flexibility.
For Kubernetes-native workflows, check out GitOps with ArgoCD. If you’re building and deploying Docker images as part of your infrastructure, see advanced Docker multi-stage builds for production best practices.
Next step: Pick a tool, deploy a real environment, and automate your infrastructure. Your future self—and your team—will thank you.
For official documentation, see:
- Terraform Documentation
- Pulumi Documentation
- AWS CloudFormation Docs

