Skip to main content
โšก Calmops

Infrastructure as Code: Terraform vs CloudFormation vs Pulumi

Introduction

Infrastructure as Code (IaC) has revolutionized how organizations manage cloud infrastructure. Instead of manually clicking through cloud consoles, IaC enables teams to define infrastructure in code, version it, test it, and deploy it consistently. However, choosing the right IaC tool is criticalโ€”the wrong choice can lead to vendor lock-in, steep learning curves, and maintenance nightmares.

This comprehensive guide compares three major IaC platformsโ€”Terraform, CloudFormation, and Pulumiโ€”with practical examples and real-world deployment patterns.


Core Concepts & Terminology

Infrastructure as Code (IaC)

Managing infrastructure through code rather than manual processes, enabling version control, testing, and automation.

Declarative IaC

Specifying desired state; the tool determines how to achieve it (Terraform, CloudFormation).

Imperative IaC

Specifying exact steps to achieve desired state (Pulumi, scripts).

State Management

Tracking current infrastructure state to determine what changes are needed.

Plan

Preview of changes that will be applied to infrastructure.

Apply

Executing planned changes to create or modify infrastructure.

Destroy

Removing infrastructure managed by IaC.

Module

Reusable collection of resources and configurations.

Variable

Input parameter for IaC configurations.

Output

Value exported from IaC configuration for use by other systems.

Provider

Plugin enabling IaC tool to interact with specific cloud platform (AWS, Azure, GCP).

Resource

Individual infrastructure component (EC2 instance, S3 bucket, RDS database).

Data Source

Read-only reference to existing infrastructure.


IaC Platform Comparison

Feature Comparison Matrix

Feature Terraform CloudFormation Pulumi
Language HCL JSON/YAML Python, Go, TypeScript, C#
Cloud Support Multi-cloud AWS only Multi-cloud
Learning Curve Moderate Steep Gentle (if familiar with language)
State Management Explicit Implicit Explicit
Modularity Excellent Good Excellent
Community Largest Medium Growing
Cost Free Free Free (open-source)
Enterprise Support Terraform Cloud AWS Support Pulumi Cloud
Best For Multi-cloud AWS-only Polyglot teams

Terraform

Architecture Overview

Terraform Configuration
โ”œโ”€โ”€ main.tf
โ”œโ”€โ”€ variables.tf
โ”œโ”€โ”€ outputs.tf
โ”œโ”€โ”€ terraform.tfvars
โ””โ”€โ”€ terraform.tfstate

Workflow:
1. Write configuration (HCL)
2. terraform init (initialize)
3. terraform plan (preview changes)
4. terraform apply (execute changes)
5. terraform destroy (remove resources)

Basic Configuration Example

# Configure AWS provider
terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

# Variables
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Environment name"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "instance_count" {
  description = "Number of instances"
  type        = number
  default     = 2
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

# Subnets
resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.${count.index + 1}.0/24"
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.environment}-public-subnet-${count.index + 1}"
  }
}

# Security Group
resource "aws_security_group" "web" {
  name        = "${var.environment}-web-sg"
  description = "Security group for web servers"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    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 = "${var.environment}-web-sg"
  }
}

# EC2 Instances
resource "aws_instance" "web" {
  count                = var.instance_count
  ami                  = data.aws_ami.ubuntu.id
  instance_type        = "t3.medium"
  subnet_id            = aws_subnet.public[count.index % 2].id
  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = base64encode(templatefile("${path.module}/user_data.sh", {
    environment = var.environment
  }))

  tags = {
    Name = "${var.environment}-web-${count.index + 1}"
  }

  depends_on = [aws_internet_gateway.main]
}

# Load Balancer
resource "aws_lb" "main" {
  name               = "${var.environment}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.web.id]
  subnets            = aws_subnet.public[*].id

  tags = {
    Name = "${var.environment}-alb"
  }
}

resource "aws_lb_target_group" "web" {
  name        = "${var.environment}-tg"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main.id
  target_type = "instance"

  health_check {
    healthy_threshold   = 2
    unhealthy_threshold = 2
    timeout             = 3
    interval            = 30
    path                = "/"
    matcher             = "200"
  }
}

resource "aws_lb_target_group_attachment" "web" {
  count            = var.instance_count
  target_group_arn = aws_lb_target_group.web.arn
  target_id        = aws_instance.web[count.index].id
  port             = 80
}

resource "aws_lb_listener" "web" {
  load_balancer_arn = aws_lb.main.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.web.arn
  }
}

# Data sources
data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# Outputs
output "load_balancer_dns" {
  description = "DNS name of load balancer"
  value       = aws_lb.main.dns_name
}

output "instance_ips" {
  description = "Private IPs of instances"
  value       = aws_instance.web[*].private_ip
}

Terraform Modules

# modules/vpc/main.tf
variable "cidr_block" {
  type = string
}

variable "environment" {
  type = string
}

resource "aws_vpc" "main" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

resource "aws_subnet" "public" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 1}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "${var.environment}-public-subnet-${count.index + 1}"
  }
}

output "vpc_id" {
  value = aws_vpc.main.id
}

output "subnet_ids" {
  value = aws_subnet.public[*].id
}

# main.tf (using module)
module "vpc" {
  source = "./modules/vpc"

  cidr_block  = "10.0.0.0/16"
  environment = var.environment
}

module "security_group" {
  source = "./modules/security_group"

  vpc_id      = module.vpc.vpc_id
  environment = var.environment
}

Terraform State Management

# Backend configuration
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

# Remote state reference
data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket = "my-terraform-state"
    key    = "prod/vpc/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_security_group" "app" {
  vpc_id = data.terraform_remote_state.vpc.outputs.vpc_id
  # ...
}

CloudFormation

Architecture Overview

CloudFormation Template
โ”œโ”€โ”€ AWSTemplateFormatVersion
โ”œโ”€โ”€ Description
โ”œโ”€โ”€ Parameters
โ”œโ”€โ”€ Resources
โ”œโ”€โ”€ Outputs
โ””โ”€โ”€ Metadata

Workflow:
1. Create template (JSON/YAML)
2. Create stack
3. Monitor stack events
4. Update stack
5. Delete stack

Basic Template Example

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Web application infrastructure'

Parameters:
  EnvironmentName:
    Type: String
    Default: prod
    AllowedValues: [dev, staging, prod]
  
  InstanceCount:
    Type: Number
    Default: 2
    MinValue: 1
    MaxValue: 10

Resources:
  # VPC
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-vpc'

  # Public Subnets
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-public-subnet-1'

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.2.0/24
      AvailabilityZone: !Select [1, !GetAZs '']
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-public-subnet-2'

  # Internet Gateway
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-igw'

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

  # Route Table
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-public-rt'

  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  SubnetRouteTableAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable

  SubnetRouteTableAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable

  # Security Group
  WebSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for web servers
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-web-sg'

  # EC2 Instances
  WebInstance1:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Sub '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2}}'
      InstanceType: t3.medium
      SubnetId: !Ref PublicSubnet1
      SecurityGroupIds:
        - !Ref WebSecurityGroup
      UserData:
        Fn::Base64: |
          #!/bin/bash
          yum update -y
          yum install -y httpd
          systemctl start httpd
          systemctl enable httpd
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-web-1'

  WebInstance2:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Sub '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2}}'
      InstanceType: t3.medium
      SubnetId: !Ref PublicSubnet2
      SecurityGroupIds:
        - !Ref WebSecurityGroup
      UserData:
        Fn::Base64: |
          #!/bin/bash
          yum update -y
          yum install -y httpd
          systemctl start httpd
          systemctl enable httpd
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-web-2'

  # Load Balancer
  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub '${EnvironmentName}-alb'
      Type: application
      Scheme: internet-facing
      SecurityGroups:
        - !Ref WebSecurityGroup
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-alb'

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub '${EnvironmentName}-tg'
      Port: 80
      Protocol: HTTP
      VpcId: !Ref VPC
      TargetType: instance
      Targets:
        - Id: !Ref WebInstance1
        - Id: !Ref WebInstance2
      HealthCheckEnabled: true
      HealthCheckPath: /
      HealthCheckProtocol: HTTP
      HealthCheckIntervalSeconds: 30
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 2

  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup

Outputs:
  LoadBalancerDNS:
    Description: DNS name of load balancer
    Value: !GetAtt LoadBalancer.DNSName
    Export:
      Name: !Sub '${EnvironmentName}-alb-dns'

  VPCId:
    Description: VPC ID
    Value: !Ref VPC
    Export:
      Name: !Sub '${EnvironmentName}-vpc-id'

Pulumi

Architecture Overview

Pulumi Program
โ”œโ”€โ”€ __main__.py (or main.go, main.ts)
โ”œโ”€โ”€ Pulumi.yaml
โ””โ”€โ”€ Pulumi.prod.yaml

Workflow:
1. Write program (Python/Go/TypeScript/C#)
2. pulumi up (preview and apply)
3. pulumi destroy (remove resources)

Python Example

import pulumi
import pulumi_aws as aws

# Configuration
config = pulumi.Config()
environment = config.get('environment') or 'prod'
instance_count = config.get_int('instance_count') or 2

# VPC
vpc = aws.ec2.Vpc('main',
    cidr_block='10.0.0.0/16',
    enable_dns_hostnames=True,
    enable_dns_support=True,
    tags={
        'Name': f'{environment}-vpc',
        'Environment': environment,
    })

# Subnets
subnets = []
for i in range(2):
    subnet = aws.ec2.Subnet(f'public-{i}',
        vpc_id=vpc.id,
        cidr_block=f'10.0.{i+1}.0/24',
        availability_zone=aws.get_availability_zones(state='available').names[i],
        map_public_ip_on_launch=True,
        tags={
            'Name': f'{environment}-public-subnet-{i+1}',
        })
    subnets.append(subnet)

# Internet Gateway
igw = aws.ec2.InternetGateway('main',
    vpc_id=vpc.id,
    tags={
        'Name': f'{environment}-igw',
    })

# Route Table
route_table = aws.ec2.RouteTable('public',
    vpc_id=vpc.id,
    routes=[
        aws.ec2.RouteTableRouteArgs(
            cidr_block='0.0.0.0/0',
            gateway_id=igw.id,
        ),
    ],
    tags={
        'Name': f'{environment}-public-rt',
    })

# Associate subnets with route table
for i, subnet in enumerate(subnets):
    aws.ec2.RouteTableAssociation(f'public-{i}',
        subnet_id=subnet.id,
        route_table_id=route_table.id)

# Security Group
security_group = aws.ec2.SecurityGroup('web',
    vpc_id=vpc.id,
    description='Security group for web servers',
    ingress=[
        aws.ec2.SecurityGroupIngressArgs(
            protocol='tcp',
            from_port=80,
            to_port=80,
            cidr_blocks=['0.0.0.0/0'],
        ),
        aws.ec2.SecurityGroupIngressArgs(
            protocol='tcp',
            from_port=443,
            to_port=443,
            cidr_blocks=['0.0.0.0/0'],
        ),
    ],
    egress=[
        aws.ec2.SecurityGroupEgressArgs(
            protocol='-1',
            from_port=0,
            to_port=0,
            cidr_blocks=['0.0.0.0/0'],
        ),
    ],
    tags={
        'Name': f'{environment}-web-sg',
    })

# Get latest Ubuntu AMI
ubuntu_ami = aws.ec2.get_ami(
    most_recent=True,
    owners=['099720109477'],
    filters=[
        aws.ec2.GetAmiFilterArgs(
            name='name',
            values=['ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*'],
        ),
    ])

# EC2 Instances
instances = []
for i in range(instance_count):
    instance = aws.ec2.Instance(f'web-{i}',
        ami=ubuntu_ami.id,
        instance_type='t3.medium',
        subnet_id=subnets[i % 2].id,
        vpc_security_group_ids=[security_group.id],
        user_data='''#!/bin/bash
apt-get update
apt-get install -y apache2
systemctl start apache2
systemctl enable apache2
''',
        tags={
            'Name': f'{environment}-web-{i+1}',
        })
    instances.append(instance)

# Load Balancer
load_balancer = aws.lb.LoadBalancer('main',
    internal=False,
    load_balancer_type='application',
    security_groups=[security_group.id],
    subnets=[subnet.id for subnet in subnets],
    tags={
        'Name': f'{environment}-alb',
    })

# Target Group
target_group = aws.lb.TargetGroup('web',
    port=80,
    protocol='HTTP',
    vpc_id=vpc.id,
    target_type='instance',
    health_check=aws.lb.TargetGroupHealthCheckArgs(
        healthy_threshold=2,
        unhealthy_threshold=2,
        timeout=3,
        interval=30,
        path='/',
        matcher='200',
    ),
    tags={
        'Name': f'{environment}-tg',
    })

# Target Group Attachments
for i, instance in enumerate(instances):
    aws.lb.TargetGroupAttachment(f'web-{i}',
        target_group_arn=target_group.arn,
        target_id=instance.id,
        port=80)

# Listener
listener = aws.lb.Listener('web',
    load_balancer_arn=load_balancer.arn,
    port=80,
    protocol='HTTP',
    default_actions=[
        aws.lb.ListenerDefaultActionArgs(
            type='forward',
            target_group_arn=target_group.arn,
        ),
    ])

# Outputs
pulumi.export('load_balancer_dns', load_balancer.dns_name)
pulumi.export('instance_ips', [instance.private_ip for instance in instances])

Best Practices & Common Pitfalls

Best Practices

  1. Version Control: Store IaC in Git with proper branching
  2. State Management: Use remote state with encryption and locking
  3. Modularity: Create reusable modules for common patterns
  4. Testing: Test IaC changes in non-production environments
  5. Documentation: Document infrastructure decisions and configurations
  6. Secrets Management: Never commit secrets, use secure storage
  7. Code Review: Review IaC changes before applying
  8. Monitoring: Monitor infrastructure changes and drift
  9. Disaster Recovery: Test recovery procedures regularly
  10. Cost Optimization: Review infrastructure costs regularly

Common Pitfalls

  1. State Corruption: Losing or corrupting state files
  2. Manual Changes: Making manual changes outside IaC
  3. Tight Coupling: Infrastructure too tightly coupled
  4. Inadequate Testing: Deploying untested infrastructure
  5. Secrets in Code: Committing secrets to version control
  6. No Rollback Plan: Unable to revert failed deployments
  7. Insufficient Documentation: Team can’t understand infrastructure
  8. Drift Detection: Not detecting infrastructure drift
  9. Scaling Issues: Infrastructure not scaling properly
  10. Cost Overruns: Unexpected infrastructure costs

External Resources

Terraform

CloudFormation

Pulumi


Conclusion

Infrastructure as Code is essential for modern cloud operations. Terraform excels for multi-cloud deployments with its large ecosystem and community. CloudFormation is ideal for AWS-only environments with tight AWS integration. Pulumi offers flexibility for teams comfortable with programming languages.

Regardless of tool choice, focus on proper state management, modularity, testing, and documentation. Invest in automation and monitoring to ensure infrastructure reliability and cost efficiency.

Start with simple infrastructure, gradually add complexity, and continuously optimize based on real-world experience and feedback.

Comments