Infrastructure as Code

Declarative vs programmatic infrastructure, Terraform, CloudFormation, AWS CDK, Pulumi, and Ansible

Infrastructure as Code: Code Your Cloud

Clicking through cloud consoles doesn't scale. Infrastructure as Code (IaC) treats infrastructure like software: versioned, tested, reviewed, and automated. But not all IaC is created equal. Declarative tools like Terraform and CloudFormation use configuration files to describe desired state. Programmatic tools like AWS CDK and Pulumi use real programming languages, giving you the full power of code: loops, conditionals, functions, type safety, and IDE support. This lesson covers both approaches and when to use each.

IaC Fundamentals, Why and What

Infrastructure as Code means defining your infrastructure in code files instead of manual configuration.

❌ Manual Infrastructure
  • Click through AWS console
  • No version control
  • Hard to replicate environments
  • Tribal knowledge, documentation outdated
  • Drift between dev/staging/prod
  • Disaster recovery = panic
  • Can't review infrastructure changes
✅ Infrastructure as Code
  • Define infrastructure in code files
  • Version control (Git)
  • Identical environments via code
  • Self-documenting
  • Environment parity guaranteed
  • Disaster recovery = re-run code
  • Code reviews for infra changes

Key Benefits

Reproducibility
  • Create identical environments on demand
  • Dev = Staging = Production (except config)
  • Spin up test environment, destroy after tests
Version Control
  • Every change tracked in Git
  • Who changed what, when, and why
  • Easy rollback: git revert
  • Code review for infrastructure
Automation
  • CI/CD for infrastructure
  • Automated testing (linting, dry-run)
  • Scheduled updates (patch Tuesday)
  • Self-service for developers
Documentation
  • Code is the documentation
  • Always up-to-date (unlike wiki)
  • Onboarding: read the code
  • Diagram generation from code
Cost Management
  • Destroy environments when not needed
  • Standardize instance types
  • Enforce tagging for cost allocation
  • Prevent over-provisioning

Declarative vs Programmatic IaC

AspectDeclarative (Terraform, CloudFormation)Programmatic (AWS CDK, Pulumi)
LanguageDSL (HCL, YAML, JSON)Real programming languages (Python, TypeScript, Go)
Approach"What" you want (desired state)"How" to create it (imperative + declarative)
AbstractionLimited (config-based)High (functions, classes, packages)
ReusabilityModules (somewhat clunky)Full OOP, npm/pip packages
IDE SupportLimited autocompleteFull IDE features (IntelliSense, refactoring)
TestingLimited unit testingFull testing frameworks (Jest, pytest)
Learning CurveNew DSL to learnUse languages you already know
FlexibilityConstrained by DSLFull programming power (loops, conditionals, etc.)

Terraform, Declarative Multi-Cloud IaC

Terraform uses HashiCorp Configuration Language (HCL) to declare infrastructure. It's cloud-agnostic, has a massive provider ecosystem, and is the industry standard for declarative IaC.

Basic Terraform Structure

# main.tf
# Provider configuration
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket = "my-terraform-state"
    key    = "prod/terraform.tfstate"
    region = "us-east-1"
  }
}

provider "aws" {
  region = var.aws_region
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

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

# Subnet
resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone = var.availability_zones[count.index]

  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-${var.availability_zones[count.index]}"
  }
}

# Security Group
resource "aws_security_group" "web" {
  name        = "${var.project_name}-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"]
  }
}

# EC2 Instance
resource "aws_instance" "web" {
  count                  = var.instance_count
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public[count.index % length(aws_subnet.public)].id
  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = file("user-data.sh")

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

# Data source for latest Ubuntu AMI
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}
# variables.tf
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project name"
  type        = string
}

variable "environment" {
  description = "Environment (dev, staging, prod)"
  type        = string
}

variable "vpc_cidr" {
  description = "VPC CIDR block"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "Availability zones"
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

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

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}
# outputs.tf
output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

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

output "security_group_id" {
  description = "Security group ID"
  value       = aws_security_group.web.id
}

Terraform Workflow

# Initialize (download providers)
$ terraform init

# Format code
$ terraform fmt

# Validate configuration
$ terraform validate

# Plan (dry-run, shows what will change)
$ terraform plan -var-file="prod.tfvars" -out=plan.out

# Apply changes
$ terraform apply plan.out

# Show current state
$ terraform show

# List resources
$ terraform state list

# Import existing resource
$ terraform import aws_instance.web i-1234567890abcdef0

# Destroy everything
$ terraform destroy -var-file="prod.tfvars"

Terraform Modules (Reusability)

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

resource "aws_vpc" "this" {
  cidr_block = var.vpc_cidr
  tags = { Name = var.name }
}

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

output "vpc_cidr" {
  value = aws_vpc.this.cidr_block
}
# main.tf (using module)
module "vpc" {
  source   = "./modules/vpc"
  vpc_cidr = "10.0.0.0/16"
  name     = "production-vpc"
}

# Use module outputs
resource "aws_subnet" "example" {
  vpc_id     = module.vpc.vpc_id
  cidr_block = "10.0.1.0/24"
}

# Or use public modules from registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway = true
  enable_vpn_gateway = false
}
Terraform Strengths:
• Cloud-agnostic (AWS, GCP, Azure, 3000+ providers)
• Huge community and module registry
• Mature, battle-tested
• Good state management

Limitations:
• HCL has limited expressiveness (no real loops, functions)
• Module reusability is clunky
• Testing is limited
• Refactoring is painful

AWS CDK, Programmatic Infrastructure with TypeScript/Python

AWS CDK (Cloud Development Kit) lets you define AWS infrastructure using real programming languages. It synthesizes to CloudFormation but gives you the full power of code: classes, functions, loops, conditionals, type safety, and IDE support.

CDK with TypeScript

# lib/my-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns';
import * as rds from 'aws-cdk-lib/aws-rds';
import { Construct } from 'constructs';

export interface MyStackProps extends cdk.StackProps {
  environment: 'dev' | 'staging' | 'prod';
  instanceCount: number;
}

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    // VPC with public and private subnets
    const vpc = new ec2.Vpc(this, 'VPC', {
      maxAzs: 3,
      natGateways: props.environment === 'prod' ? 2 : 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'public',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: 'private',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
        {
          cidrMask: 28,
          name: 'isolated',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });

    // ECS Cluster
    const cluster = new ecs.Cluster(this, 'Cluster', {
      vpc,
      containerInsights: props.environment === 'prod',
    });

    // Fargate Service with ALB
    const fargateService = new ecs_patterns.ApplicationLoadBalancedFargateService(
      this,
      'FargateService',
      {
        cluster,
        cpu: this.getCpuForEnvironment(props.environment),
        memoryLimitMiB: this.getMemoryForEnvironment(props.environment),
        desiredCount: props.instanceCount,
        taskImageOptions: {
          image: ecs.ContainerImage.fromRegistry('nginx:latest'),
          environment: {
            ENVIRONMENT: props.environment,
          },
        },
        publicLoadBalancer: true,
      }
    );

    // Auto-scaling based on CPU
    const scaling = fargateService.service.autoScaleTaskCount({
      minCapacity: props.instanceCount,
      maxCapacity: props.instanceCount * 3,
    });

    scaling.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 70,
      scaleInCooldown: cdk.Duration.seconds(60),
      scaleOutCooldown: cdk.Duration.seconds(60),
    });

    // RDS Database (only in staging/prod)
    if (props.environment !== 'dev') {
      const database = new rds.DatabaseInstance(this, 'Database', {
        engine: rds.DatabaseInstanceEngine.postgres({
          version: rds.PostgresEngineVersion.VER_15,
        }),
        vpc,
        vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
        instanceType: ec2.InstanceType.of(
          ec2.InstanceClass.T4G,
          props.environment === 'prod' ? ec2.InstanceSize.MEDIUM : ec2.InstanceSize.SMALL
        ),
        multiAz: props.environment === 'prod',
        allocatedStorage: props.environment === 'prod' ? 100 : 20,
        maxAllocatedStorage: props.environment === 'prod' ? 200 : 50,
        deletionProtection: props.environment === 'prod',
        backupRetention: cdk.Duration.days(props.environment === 'prod' ? 7 : 1),
      });

      // Allow connections from Fargate service
      database.connections.allowFrom(
        fargateService.service,
        ec2.Port.tcp(5432)
      );
    }

    // Outputs
    new cdk.CfnOutput(this, 'LoadBalancerDNS', {
      value: fargateService.loadBalancer.loadBalancerDnsName,
      description: 'DNS name of the load balancer',
    });
  }

  // Helper methods - this is real programming!
  private getCpuForEnvironment(env: string): number {
    const cpuMap = { dev: 256, staging: 512, prod: 1024 };
    return cpuMap[env as keyof typeof cpuMap];
  }

  private getMemoryForEnvironment(env: string): number {
    const memoryMap = { dev: 512, staging: 1024, prod: 2048 };
    return memoryMap[env as keyof typeof memoryMap];
  }
}
# bin/app.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { MyStack } from '../lib/my-stack';

const app = new cdk.App();

// Create multiple environments programmatically
const environments = [
  { name: 'dev', instanceCount: 1 },
  { name: 'staging', instanceCount: 2 },
  { name: 'prod', instanceCount: 3 },
] as const;

for (const env of environments) {
  new MyStack(app, `MyStack-${env.name}`, {
    environment: env.name,
    instanceCount: env.instanceCount,
    env: {
      account: process.env.CDK_DEFAULT_ACCOUNT,
      region: process.env.CDK_DEFAULT_REGION,
    },
    tags: {
      Environment: env.name,
      ManagedBy: 'CDK',
    },
  });
}

CDK with Python

# app.py
from aws_cdk import (
    Stack,
    aws_s3 as s3,
    aws_lambda as lambda_,
    aws_apigateway as apigw,
    aws_dynamodb as dynamodb,
    RemovalPolicy,
    Duration,
)
from constructs import Construct

class ServerlessApiStack(Stack):
    def __init__(self, scope: Construct, id: str, **kwargs):
        super().__init__(scope, id, **kwargs)

        # DynamoDB Table
        table = dynamodb.Table(
            self, "ItemsTable",
            partition_key=dynamodb.Attribute(
                name="id",
                type=dynamodb.AttributeType.STRING
            ),
            billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
            removal_policy=RemovalPolicy.DESTROY,  # For demo only
        )

        # Lambda Function
        handler = lambda_.Function(
            self, "ApiHandler",
            runtime=lambda_.Runtime.PYTHON_3_11,
            code=lambda_.Code.from_asset("lambda"),
            handler="handler.main",
            environment={
                "TABLE_NAME": table.table_name,
            },
            timeout=Duration.seconds(30),
        )

        # Grant Lambda permissions to DynamoDB
        table.grant_read_write_data(handler)

        # API Gateway
        api = apigw.RestApi(
            self, "ItemsApi",
            rest_api_name="Items Service",
            description="Serverless API with DynamoDB backend",
        )

        # Add endpoints
        items = api.root.add_resource("items")
        items.add_method("GET", apigw.LambdaIntegration(handler))
        items.add_method("POST", apigw.LambdaIntegration(handler))

        item = items.add_resource("{id}")
        item.add_method("GET", apigw.LambdaIntegration(handler))
        item.add_method("PUT", apigw.LambdaIntegration(handler))
        item.add_method("DELETE", apigw.LambdaIntegration(handler))

# Example of creating custom constructs (reusable components)
class WebsiteConstruct(Construct):
    """Reusable construct for static website"""

    def __init__(self, scope: Construct, id: str, domain_name: str):
        super().__init__(scope, id)

        # S3 bucket for website
        self.bucket = s3.Bucket(
            self, "WebsiteBucket",
            website_index_document="index.html",
            public_read_access=True,
            removal_policy=RemovalPolicy.DESTROY,
            auto_delete_objects=True,
        )

# Use the custom construct
app = cdk.App()
stack = Stack(app, "MyWebsiteStack")
website = WebsiteConstruct(stack, "Website", domain_name="example.com")

CDK Workflow

# Install CDK
$ npm install -g aws-cdk

# Create new project
$ cdk init app --language typescript
# or: --language python

# Install dependencies
$ npm install

# Synthesize CloudFormation template
$ cdk synth

# Diff against deployed stack
$ cdk diff

# Deploy
$ cdk deploy

# Deploy all stacks
$ cdk deploy --all

# Deploy specific stack
$ cdk deploy MyStack-prod

# Destroy
$ cdk destroy

# List all stacks
$ cdk list
CDK Advantages:
• Real programming languages (TypeScript, Python, Java, C#, Go)
• Full IDE support (autocomplete, type checking, refactoring)
• Reusable constructs (like npm packages)
• Unit testing with familiar frameworks
• Higher-level abstractions (L2/L3 constructs)
• Imperative + declarative programming

Considerations:
• AWS-only (not multi-cloud)
• Synthesizes to CloudFormation (inherits CF limits)
• Learning curve if new to programming

Pulumi, Multi-Cloud Programmatic IaC

Pulumi is like CDK but multi-cloud. Define infrastructure in TypeScript, Python, Go, C#, or Java. It manages state directly (not through CloudFormation) and supports AWS, GCP, Azure, Kubernetes, and more.

Pulumi with TypeScript

# index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";

// Configuration
const config = new pulumi.Config();
const instanceCount = config.getNumber("instanceCount") || 2;
const environment = pulumi.getStack(); // dev, staging, prod

// VPC
const vpc = new awsx.ec2.Vpc("my-vpc", {
  numberOfAvailabilityZones: 3,
  natGateways: {
    strategy: environment === "prod" ? "OnePerAz" : "Single",
  },
  tags: {
    Environment: environment,
  },
});

// ECS Cluster
const cluster = new aws.ecs.Cluster("cluster", {
  vpcId: vpc.vpcId,
  tags: { Environment: environment },
});

// Application Load Balancer
const alb = new awsx.lb.ApplicationLoadBalancer("app-lb", {
  vpc: vpc,
  external: true,
});

// Fargate Service
const service = new awsx.ecs.FargateService("app-service", {
  cluster: cluster.arn,
  desiredCount: instanceCount,
  taskDefinitionArgs: {
    container: {
      image: "nginx:latest",
      cpu: getCpuForEnvironment(environment),
      memory: getMemoryForEnvironment(environment),
      essential: true,
      portMappings: [{
        containerPort: 80,
        targetGroup: alb.defaultTargetGroup,
      }],
    },
  },
  networkConfiguration: {
    subnets: vpc.privateSubnetIds,
    securityGroups: [cluster.securityGroups[0].id],
  },
});

// RDS Database (conditional)
let database: aws.rds.Instance | undefined;
if (environment !== "dev") {
  database = new aws.rds.Instance("postgres", {
    engine: "postgres",
    engineVersion: "15.3",
    instanceClass: environment === "prod" ? "db.t4g.medium" : "db.t4g.small",
    allocatedStorage: environment === "prod" ? 100 : 20,
    dbSubnetGroupName: vpc.privateSubnetIds.apply(subnets =>
      new aws.rds.SubnetGroup("db-subnet", {
        subnetIds: subnets,
      }).name
    ),
    multiAz: environment === "prod",
    backupRetentionPeriod: environment === "prod" ? 7 : 1,
    skipFinalSnapshot: environment !== "prod",
    vpcSecurityGroupIds: [cluster.securityGroups[0].id],
  });
}

// Outputs
export const vpcId = vpc.vpcId;
export const albDns = alb.loadBalancer.dnsName;
export const dbEndpoint = database?.endpoint;

// Helper functions
function getCpuForEnvironment(env: string): number {
  const cpuMap: Record<string, number> = {
    dev: 256,
    staging: 512,
    prod: 1024,
  };
  return cpuMap[env] || 256;
}

function getMemoryForEnvironment(env: string): number {
  const memoryMap: Record<string, number> = {
    dev: 512,
    staging: 1024,
    prod: 2048,
  };
  return memoryMap[env] || 512;
}

Pulumi with Python

# __main__.py
import pulumi
import pulumi_aws as aws
import pulumi_kubernetes as k8s
from pulumi_aws import ec2, eks

# Configuration
config = pulumi.Config()
node_count = config.get_int("node_count", 3)
node_type = config.get("node_type", "t3.medium")

# VPC for EKS
vpc = ec2.Vpc(
    "eks-vpc",
    cidr_block="10.0.0.0/16",
    enable_dns_hostnames=True,
    enable_dns_support=True,
)

# Subnets
subnet_ids = []
for i, az in enumerate(["us-east-1a", "us-east-1b", "us-east-1c"]):
    subnet = ec2.Subnet(
        f"eks-subnet-{i}",
        vpc_id=vpc.id,
        cidr_block=f"10.0.{i}.0/24",
        availability_zone=az,
        map_public_ip_on_launch=True,
    )
    subnet_ids.append(subnet.id)

# EKS Cluster
cluster = eks.Cluster(
    "eks-cluster",
    vpc_id=vpc.id,
    subnet_ids=subnet_ids,
    instance_type=node_type,
    desired_capacity=node_count,
    min_size=node_count,
    max_size=node_count * 2,
)

# Kubernetes provider
k8s_provider = k8s.Provider(
    "k8s-provider",
    kubeconfig=cluster.kubeconfig,
)

# Deploy app to Kubernetes
app = k8s.apps.v1.Deployment(
    "nginx-deployment",
    spec=k8s.apps.v1.DeploymentSpecArgs(
        replicas=3,
        selector=k8s.meta.v1.LabelSelectorArgs(
            match_labels={"app": "nginx"},
        ),
        template=k8s.core.v1.PodTemplateSpecArgs(
            metadata=k8s.meta.v1.ObjectMetaArgs(
                labels={"app": "nginx"},
            ),
            spec=k8s.core.v1.PodSpecArgs(
                containers=[
                    k8s.core.v1.ContainerArgs(
                        name="nginx",
                        image="nginx:latest",
                        ports=[k8s.core.v1.ContainerPortArgs(container_port=80)],
                    )
                ],
            ),
        ),
    ),
    opts=pulumi.ResourceOptions(provider=k8s_provider),
)

# Service
service = k8s.core.v1.Service(
    "nginx-service",
    spec=k8s.core.v1.ServiceSpecArgs(
        type="LoadBalancer",
        selector={"app": "nginx"},
        ports=[k8s.core.v1.ServicePortArgs(port=80, target_port=80)],
    ),
    opts=pulumi.ResourceOptions(provider=k8s_provider),
)

# Outputs
pulumi.export("kubeconfig", cluster.kubeconfig)
pulumi.export("cluster_name", cluster.eks_cluster.name)
pulumi.export("service_endpoint", service.status.load_balancer.ingress[0].hostname)

Pulumi Workflow

# Install Pulumi
$ curl -fsSL https://get.pulumi.com | sh

# Login (uses SaaS backend by default)
$ pulumi login
# Or use self-hosted: pulumi login s3://my-state-bucket

# Create new project
$ pulumi new aws-typescript
# or: aws-python, azure-python, kubernetes-go, etc.

# Install dependencies
$ npm install  # or: pip install -r requirements.txt

# Preview changes
$ pulumi preview

# Deploy
$ pulumi up

# View outputs
$ pulumi stack output

# Destroy
$ pulumi destroy

# Manage stacks (like environments)
$ pulumi stack init dev
$ pulumi stack select prod
$ pulumi stack ls

Unit Testing Infrastructure

# infrastructure.test.ts
import * as pulumi from "@pulumi/pulumi";
import { expect } from "chai";
import "mocha";

// Mock Pulumi runtime
pulumi.runtime.setMocks({
  newResource: (args: pulumi.runtime.MockResourceArgs) => {
    return {
      id: args.name + "_id",
      state: args.inputs,
    };
  },
  call: (args: pulumi.runtime.MockCallArgs) => {
    return args.inputs;
  },
});

describe("Infrastructure Tests", () => {
  let infra: typeof import("./index");

  before(async () => {
    infra = await import("./index");
  });

  it("should create VPC with correct CIDR", (done) => {
    pulumi.all([infra.vpcId]).apply(([vpcId]) => {
      expect(vpcId).to.not.be.undefined;
      done();
    });
  });

  it("should create correct number of instances", (done) => {
    pulumi.all([infra.instanceCount]).apply(([count]) => {
      expect(count).to.equal(2);
      done();
    });
  });
});

// Run: npm test
Pulumi Advantages:
• Multi-cloud (AWS, GCP, Azure, K8s, 100+ providers)
• Real programming languages with full ecosystem
• Unit testing with Jest, pytest, etc.
• Direct state management (no CloudFormation)
• Secrets management built-in
• Policy as code (Pulumi CrossGuard)

Considerations:
• Newer than Terraform (smaller community)
• SaaS backend (or self-host state)
• Learning curve for declarative → imperative thinking

AWS CloudFormation, AWS Native Declarative IaC

CloudFormation is AWS's native IaC service. It uses JSON or YAML templates to declare infrastructure. It's deeply integrated with AWS but verbose and limited compared to programmatic options.

CloudFormation Template

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: 'Web application infrastructure'

Parameters:
  EnvironmentName:
    Type: String
    AllowedValues: [dev, staging, prod]
    Default: dev

  InstanceType:
    Type: String
    Default: t3.micro
    AllowedValues: [t3.micro, t3.small, t3.medium]

  KeyName:
    Type: AWS::EC2::KeyPair::KeyName
    Description: EC2 Key Pair

Mappings:
  EnvironmentConfig:
    dev:
      InstanceCount: 1
      DBInstanceClass: db.t4g.micro
    staging:
      InstanceCount: 2
      DBInstanceClass: db.t4g.small
    prod:
      InstanceCount: 3
      DBInstanceClass: db.t4g.medium

Conditions:
  IsProduction: !Equals [!Ref EnvironmentName, prod]
  CreateDatabase: !Not [!Equals [!Ref EnvironmentName, dev]]

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 Subnet
  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-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-2'

  # Internet Gateway
  InternetGateway:
    Type: AWS::EC2::InternetGateway

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

  # Security Group
  WebServerSecurityGroup:
    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

  # Launch Template
  LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub '${EnvironmentName}-template'
      LaunchTemplateData:
        ImageId: !Sub '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2}}'
        InstanceType: !Ref InstanceType
        KeyName: !Ref KeyName
        SecurityGroupIds:
          - !Ref WebServerSecurityGroup
        UserData:
          Fn::Base64: !Sub |
            #!/bin/bash
            yum update -y
            yum install -y httpd
            systemctl start httpd
            systemctl enable httpd
            echo "<h1>Hello from ${EnvironmentName}</h1>" > /var/www/html/index.html

  # Auto Scaling Group
  AutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      LaunchTemplate:
        LaunchTemplateId: !Ref LaunchTemplate
        Version: !GetAtt LaunchTemplate.LatestVersionNumber
      MinSize: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, InstanceCount]
      MaxSize: !If [IsProduction, 10, 5]
      DesiredCapacity: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, InstanceCount]
      VPCZoneIdentifier:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      TargetGroupARNs:
        - !Ref TargetGroup
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-web'
          PropagateAtLaunch: true

  # Load Balancer
  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      SecurityGroups:
        - !Ref WebServerSecurityGroup

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      VpcId: !Ref VPC
      Port: 80
      Protocol: HTTP
      HealthCheckPath: /
      HealthCheckIntervalSeconds: 30

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

  # RDS Database (conditional)
  Database:
    Type: AWS::RDS::DBInstance
    Condition: CreateDatabase
    Properties:
      DBInstanceClass: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, DBInstanceClass]
      Engine: postgres
      EngineVersion: '15.3'
      MasterUsername: admin
      MasterUserPassword: !Sub '{{resolve:secretsmanager:db-password}}'
      AllocatedStorage: !If [IsProduction, 100, 20]
      MultiAZ: !If [IsProduction, true, false]
      BackupRetentionPeriod: !If [IsProduction, 7, 1]
      VPCSecurityGroups:
        - !Ref WebServerSecurityGroup

Outputs:
  LoadBalancerDNS:
    Description: DNS name of the load balancer
    Value: !GetAtt LoadBalancer.DNSName
    Export:
      Name: !Sub '${AWS::StackName}-LoadBalancerDNS'

  VpcId:
    Description: VPC ID
    Value: !Ref VPC
    Export:
      Name: !Sub '${AWS::StackName}-VpcId'

CloudFormation CLI

# Validate template
$ aws cloudformation validate-template --template-body file://template.yaml

# Create stack
$ aws cloudformation create-stack \
  --stack-name my-app-prod \
  --template-body file://template.yaml \
  --parameters ParameterKey=EnvironmentName,ParameterValue=prod \
  --capabilities CAPABILITY_IAM

# Update stack
$ aws cloudformation update-stack \
  --stack-name my-app-prod \
  --template-body file://template.yaml \
  --parameters ParameterKey=EnvironmentName,ParameterValue=prod

# Delete stack
$ aws cloudformation delete-stack --stack-name my-app-prod

# Describe stack
$ aws cloudformation describe-stacks --stack-name my-app-prod

# List stacks
$ aws cloudformation list-stacks

# Get outputs
$ aws cloudformation describe-stacks \
  --stack-name my-app-prod \
  --query 'Stacks[0].Outputs'
CloudFormation Strengths:
• Native AWS integration
• No external dependencies
• Rollback on failure
• Change sets for preview

Limitations:
• Verbose YAML/JSON
• Limited reusability
• AWS-only
• Intrinsic functions are clunky
• Hard to test
→ Consider using AWS CDK instead (same backend, better DX)

Ansible, Configuration Management & Provisioning

Ansible is different from the above. It's for configuration management, installing packages, configuring services, managing files on existing servers. It's agentless (SSH-based) and uses YAML playbooks.

Ansible Playbook

# playbook.yml
---
- name: Configure web servers
  hosts: webservers
  become: yes
  vars:
    app_version: "1.2.3"
    nginx_port: 80

  tasks:
    # Update packages
    - name: Update apt cache
      apt:
        update_cache: yes
        cache_valid_time: 3600

    # Install packages
    - name: Install required packages
      apt:
        name:
          - nginx
          - python3-pip
          - git
          - ufw
        state: present

    # Configure firewall
    - name: Allow HTTP
      ufw:
        rule: allow
        port: '80'
        proto: tcp

    - name: Allow HTTPS
      ufw:
        rule: allow
        port: '443'
        proto: tcp

    - name: Enable UFW
      ufw:
        state: enabled

    # Deploy application
    - name: Clone application repository
      git:
        repo: 'https://github.com/myorg/myapp.git'
        dest: /var/www/myapp
        version: "{{ app_version }}"
      notify: Restart nginx

    # Install Python dependencies
    - name: Install app dependencies
      pip:
        requirements: /var/www/myapp/requirements.txt
        virtualenv: /var/www/myapp/venv

    # Configure Nginx
    - name: Copy nginx config
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/sites-available/myapp
      notify: Restart nginx

    - name: Enable site
      file:
        src: /etc/nginx/sites-available/myapp
        dest: /etc/nginx/sites-enabled/myapp
        state: link
      notify: Restart nginx

    # Systemd service
    - name: Copy systemd service file
      template:
        src: templates/myapp.service.j2
        dest: /etc/systemd/system/myapp.service
      notify: Restart app

    - name: Enable and start application
      systemd:
        name: myapp
        enabled: yes
        state: started
        daemon_reload: yes

  handlers:
    - name: Restart nginx
      systemd:
        name: nginx
        state: restarted

    - name: Restart app
      systemd:
        name: myapp
        state: restarted
# inventory.ini
[webservers]
web1.example.com ansible_host=192.168.1.10
web2.example.com ansible_host=192.168.1.11
web3.example.com ansible_host=192.168.1.12

[dbservers]
db1.example.com ansible_host=192.168.1.20

[production:children]
webservers
dbservers

[production:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/id_rsa

Ansible Commands

# Test connectivity
$ ansible all -i inventory.ini -m ping

# Run playbook
$ ansible-playbook -i inventory.ini playbook.yml

# Run with specific tags
$ ansible-playbook -i inventory.ini playbook.yml --tags "deploy"

# Dry run (check mode)
$ ansible-playbook -i inventory.ini playbook.yml --check

# Limit to specific hosts
$ ansible-playbook -i inventory.ini playbook.yml --limit web1.example.com

# Run ad-hoc command
$ ansible webservers -i inventory.ini -m shell -a "uptime"

# Gather facts
$ ansible webservers -i inventory.ini -m setup

Ansible Roles (Reusability)

# Directory structure
roles/
  nginx/
    tasks/
      main.yml
    templates/
      nginx.conf.j2
    handlers/
      main.yml
    defaults/
      main.yml
    vars/
      main.yml

# roles/nginx/tasks/main.yml
---
- name: Install nginx
  apt:
    name: nginx
    state: present

- name: Copy config
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: restart nginx

# Use role in playbook
---
- name: Configure servers
  hosts: webservers
  roles:
    - nginx
    - postgresql
    - myapp
Ansible Use Cases:
• Configure servers (install packages, set up services)
• Application deployment
• Security hardening
• Patch management

When to use:
• Existing servers that need configuration
• Multi-cloud configuration management
• Simple deployments

Not for:
• Provisioning cloud infrastructure (use Terraform/CDK/Pulumi)
• Complex state management

IaC Best Practices

Lessons learned from managing infrastructure as code at scale.

1. Version Control Everything

# Git repository structure
infrastructure/
├── .gitignore          # Ignore state files, secrets
├── README.md           # Documentation
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   │   ├── main.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       └── terraform.tfvars
├── modules/
│   ├── vpc/
│   ├── ecs/
│   └── rds/
└── .github/
    └── workflows/
        └── terraform.yml

# .gitignore
*.tfstate
*.tfstate.backup
.terraform/
*.tfvars  # Don't commit secrets!

2. Remote State Management

# Terraform remote state (S3 + DynamoDB locking)
terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"  # State locking
  }
}

# Why remote state?
# ✓ Team collaboration
# ✓ State locking (prevents concurrent modifications)
# ✓ Encryption at rest
# ✓ Backup and versioning
# ✓ Audit trail

3. Environment Separation

❌ Don't

Single codebase, conditionals everywhere

resource "aws_instance" "web" {
  count = var.env == "prod" ? 5 : 1
  instance_type = var.env == "prod" ?
    "t3.large" : "t3.micro"
}

Risky: Easy to break prod

✅ Do

Separate directories/stacks per environment

environments/
├── dev/
├── staging/
└── prod/

# Or separate repos/projects

Safe: Blast radius limited

4. Use Modules/Constructs

# DRY principle: Don't repeat yourself

# ❌ Copy-paste across environments
# environments/dev/main.tf
# environments/staging/main.tf
# environments/prod/main.tf
# (All with duplicate code)

# ✅ Create reusable modules
module "vpc" {
  source = "../../modules/vpc"
  environment = "prod"
  cidr_block = "10.0.0.0/16"
}

# Or use CDK constructs (even better)
const vpc = new Vpc(this, 'VPC', {
  maxAzs: 3
});

# Or Pulumi ComponentResource
class WebApp extends pulumi.ComponentResource { ... }

5. Automate with CI/CD

# .github/workflows/terraform.yml
name: Terraform

on:
  pull_request:
    paths: ['infrastructure/**']
  push:
    branches: [main]
    paths: ['infrastructure/**']

jobs:
  terraform:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infrastructure/environments/prod

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        run: terraform init

      - name: Terraform Format Check
        run: terraform fmt -check

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan -out=plan.out
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main'
        run: terraform apply plan.out

6. Test Infrastructure Code

# CDK unit test example
import { Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';

test('Creates VPC with 3 AZs', () => {
  const app = new cdk.App();
  const stack = new MyStack(app, 'TestStack', {
    environment: 'prod',
    instanceCount: 3,
  });

  const template = Template.fromStack(stack);

  // Assert VPC exists
  template.hasResourceProperties('AWS::EC2::VPC', {
    CidrBlock: '10.0.0.0/16',
  });

  // Assert 3 subnets
  template.resourceCountIs('AWS::EC2::Subnet', 3);
});
Golden Rules:
1. Never modify infrastructure manually (always through code)
2. Always use remote state with locking
3. Separate environments completely
4. Code review all infrastructure changes
5. Tag everything for cost allocation
6. Use least privilege IAM roles
7. Encrypt secrets (never commit to Git)
8. Document with comments and README
9. Plan before apply (preview changes)
10. Test in dev/staging before prod

Key Takeaways

  • IaC Philosophy: Infrastructure defined in code, versioned in Git, deployed automatically
  • Declarative (Terraform, CloudFormation): Configuration files describe desired state
  • Programmatic (AWS CDK, Pulumi): Real programming languages with full ecosystem
  • Terraform: Multi-cloud, HCL, huge community, but limited expressiveness
  • AWS CDK: TypeScript/Python for AWS, synthesizes to CloudFormation, powerful abstractions
  • Pulumi: Multi-cloud programmatic IaC, supports AWS/GCP/Azure/K8s, unit testable
  • CloudFormation: AWS native, verbose YAML/JSON, consider CDK instead
  • Ansible: Configuration management for existing servers, not infrastructure provisioning
  • Best Practices: Version control, remote state, environment separation, CI/CD, testing
  • Modern Choice: Use programmatic tools (CDK/Pulumi) for new projects, full programming power

When to Use Each Tool

ToolBest ForAvoid When
AWS CDKAWS-only projects, teams comfortable with TS/Python, need complex abstractionsMulti-cloud requirements, need CloudFormation-independent state
PulumiMulti-cloud, want programmatic IaC, team knows real programmingTeam only knows declarative config, want largest community
TerraformMulti-cloud, declarative mindset, want huge community/modulesNeed complex logic, heavy testing, dynamic infrastructure
CloudFormationAWS-only, no external tools allowed, simple use casesComplex infrastructure, use CDK instead
AnsibleConfigure existing servers, application deployment, multi-cloud configProvisioning infrastructure (use Terraform/CDK/Pulumi)
Modern Recommendation

For new projects, strongly consider programmatic IaC (AWS CDK or Pulumi) over declarative tools. The ability to use real programming languages, with loops, functions, classes, type safety, and full IDE support, makes infrastructure code more maintainable, testable, and expressive. Terraform is industry standard and battle-tested, but its DSL limitations become painful as infrastructure grows complex.