AWS for DevOps — Project 4: CloudFormation 3-Tier Application

8 minute read

Project Overview

Build a complete 3-tier web application entirely through CloudFormation — VPC networking, Application Load Balancer, Auto Scaling Group with EC2 instances, and an RDS database. One template, one command, reproducible infrastructure.

Real-World Analogy: Instead of manually building a house (clicking through the console), you’re writing the blueprint (CloudFormation template). Anyone can use the same blueprint to build an identical house — in any AWS region, anytime.

Detail Value
Difficulty ⭐⭐⭐⭐ (Expert)
Time 4-6 hours
Cost ~$1-3 (EC2 + RDS + ALB for a few hours)
Services CloudFormation, VPC, EC2, ALB, ASG, RDS, Security Groups

Architecture

┌───────────────────────────────────────────────────────────────┐
│                 CloudFormation Stack                           │
│                                                               │
│  ┌─────────────────── VPC 10.0.0.0/16 ─────────────────────┐│
│  │                                                           ││
│  │  ┌── Public Tier ──────────────────────────────────────┐ ││
│  │  │  ┌──────────────────┐   ┌──────────────────┐       │ ││
│  │  │  │  Public Sub 1a   │   │  Public Sub 1b   │       │ ││
│  │  │  │  (ALB, NAT GW)   │   │  (ALB)           │       │ ││
│  │  │  └──────────────────┘   └──────────────────┘       │ ││
│  │  └─────────────────────────────────────────────────────┘ ││
│  │                        ↕ ALB                              ││
│  │  ┌── App Tier ─────────────────────────────────────────┐ ││
│  │  │  ┌──────────────────┐   ┌──────────────────┐       │ ││
│  │  │  │  Private Sub 1a  │   │  Private Sub 1b  │       │ ││
│  │  │  │  (EC2 ASG)       │   │  (EC2 ASG)       │       │ ││
│  │  │  └──────────────────┘   └──────────────────┘       │ ││
│  │  └─────────────────────────────────────────────────────┘ ││
│  │                        ↕ Port 5432                        ││
│  │  ┌── Data Tier ────────────────────────────────────────┐ ││
│  │  │  ┌──────────────────┐   ┌──────────────────┐       │ ││
│  │  │  │  DB Sub 1a       │   │  DB Sub 1b       │       │ ││
│  │  │  │  (RDS Primary)   │   │  (RDS Standby)   │       │ ││
│  │  │  └──────────────────┘   └──────────────────┘       │ ││
│  │  └─────────────────────────────────────────────────────┘ ││
│  └───────────────────────────────────────────────────────────┘│
└───────────────────────────────────────────────────────────────┘

Step 1 — Create the CloudFormation Template

Create a file called three-tier-app.yaml:

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Production 3-Tier Web Application'

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

  VpcCIDR:
    Type: String
    Default: '10.0.0.0/16'
    Description: VPC CIDR block

  InstanceType:
    Type: String
    Default: t3.micro
    AllowedValues: [t3.micro, t3.small, t3.medium]
    Description: EC2 instance type

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

  DBMasterUsername:
    Type: String
    Default: admin
    NoEcho: true

  DBMasterPassword:
    Type: String
    NoEcho: true
    MinLength: 8
    Description: Database master password

  KeyPairName:
    Type: AWS::EC2::KeyPair::KeyName
    Description: EC2 Key Pair for SSH access

  LatestAmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64

Resources:
  # ========================
  #  NETWORKING
  # ========================
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCIDR
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-vpc'

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

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

  # 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-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'

  # Private Subnets (App Tier)
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '10.0.11.0/24'
      AvailabilityZone: !Select [0, !GetAZs '']
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-private-1'

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '10.0.12.0/24'
      AvailabilityZone: !Select [1, !GetAZs '']
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-private-2'

  # Database Subnets
  DBSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '10.0.21.0/24'
      AvailabilityZone: !Select [0, !GetAZs '']
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-db-1'

  DBSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '10.0.22.0/24'
      AvailabilityZone: !Select [1, !GetAZs '']
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-db-2'

  # NAT Gateway
  NatGatewayEIP:
    Type: AWS::EC2::EIP
    DependsOn: InternetGatewayAttachment
    Properties:
      Domain: vpc

  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatGatewayEIP.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-nat'

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

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

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

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

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-private-rt'

  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      NatGatewayId: !Ref NatGateway

  PrivateSubnet1RouteTableAssoc:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable

  PrivateSubnet2RouteTableAssoc:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable

  # ========================
  #  SECURITY GROUPS
  # ========================
  ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ALB Security Group
      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'
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-alb-sg'

  AppSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: App Server Security Group
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          SourceSecurityGroupId: !Ref ALBSecurityGroup
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-app-sg'

  DBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Database Security Group
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 5432
          ToPort: 5432
          SourceSecurityGroupId: !Ref AppSecurityGroup
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-db-sg'

  # ========================
  #  LOAD BALANCER
  # ========================
  ApplicationLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub '${EnvironmentName}-alb'
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      SecurityGroups:
        - !Ref ALBSecurityGroup
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-alb'

  ALBTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub '${EnvironmentName}-tg'
      Port: 80
      Protocol: HTTP
      VpcId: !Ref VPC
      TargetType: instance
      HealthCheckPath: /
      HealthCheckIntervalSeconds: 30
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 5

  ALBListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref ALBTargetGroup

  # ========================
  #  AUTO SCALING GROUP
  # ========================
  EC2InstanceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${EnvironmentName}-ec2-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles:
        - !Ref EC2InstanceRole

  LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub '${EnvironmentName}-lt'
      LaunchTemplateData:
        ImageId: !Ref LatestAmiId
        InstanceType: !Ref InstanceType
        KeyName: !Ref KeyPairName
        IamInstanceProfile:
          Arn: !GetAtt EC2InstanceProfile.Arn
        SecurityGroupIds:
          - !Ref AppSecurityGroup
        UserData:
          Fn::Base64: !Sub |
            #!/bin/bash
            yum update -y
            yum install -y httpd
            systemctl start httpd
            systemctl enable httpd

            # Create a simple health check page
            cat > /var/www/html/index.html << 'EOF'
            <!DOCTYPE html>
            <html>
            <head><title>3-Tier App</title></head>
            <body>
            <h1>3-Tier Application</h1>
            <p>Environment: ${EnvironmentName}</p>
            <p>Instance: $(curl -s http://169.254.169.254/latest/meta-data/instance-id)</p>
            <p>AZ: $(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone)</p>
            </body>
            </html>
            EOF
        TagSpecifications:
          - ResourceType: instance
            Tags:
              - Key: Name
                Value: !Sub '${EnvironmentName}-app-server'

  AutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      AutoScalingGroupName: !Sub '${EnvironmentName}-asg'
      LaunchTemplate:
        LaunchTemplateId: !Ref LaunchTemplate
        Version: !GetAtt LaunchTemplate.LatestVersionNumber
      MinSize: 2
      MaxSize: 4
      DesiredCapacity: 2
      VPCZoneIdentifier:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      TargetGroupARNs:
        - !Ref ALBTargetGroup
      HealthCheckType: ELB
      HealthCheckGracePeriod: 300
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-asg'
          PropagateAtLaunch: false

  ScalingPolicy:
    Type: AWS::AutoScaling::ScalingPolicy
    Properties:
      AutoScalingGroupName: !Ref AutoScalingGroup
      PolicyType: TargetTrackingScaling
      TargetTrackingConfiguration:
        PredefinedMetricSpecification:
          PredefinedMetricType: ASGAverageCPUUtilization
        TargetValue: 70

  # ========================
  #  DATABASE
  # ========================
  DBSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: Database subnet group
      SubnetIds:
        - !Ref DBSubnet1
        - !Ref DBSubnet2
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-db-subnet-group'

  Database:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Delete
    Properties:
      DBInstanceIdentifier: !Sub '${EnvironmentName}-db'
      DBInstanceClass: !Ref DBInstanceClass
      Engine: postgres
      EngineVersion: '15.4'
      MasterUsername: !Ref DBMasterUsername
      MasterUserPassword: !Ref DBMasterPassword
      AllocatedStorage: 20
      StorageType: gp3
      DBSubnetGroupName: !Ref DBSubnetGroup
      VPCSecurityGroups:
        - !Ref DBSecurityGroup
      MultiAZ: false
      PubliclyAccessible: false
      BackupRetentionPeriod: 7
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-database'

# ========================
#  OUTPUTS
# ========================
Outputs:
  VPCId:
    Description: VPC ID
    Value: !Ref VPC
    Export:
      Name: !Sub '${EnvironmentName}-VPCId'

  ALBDNSName:
    Description: Application Load Balancer DNS
    Value: !GetAtt ApplicationLoadBalancer.DNSName

  ALBUrl:
    Description: Application URL
    Value: !Sub 'http://${ApplicationLoadBalancer.DNSName}'

  DatabaseEndpoint:
    Description: RDS Endpoint
    Value: !GetAtt Database.Endpoint.Address

  DatabasePort:
    Description: RDS Port
    Value: !GetAtt Database.Endpoint.Port

Step 2 — Validate the Template

# Validate the template syntax
$ aws cloudformation validate-template \
    --template-body file://three-tier-app.yaml
{
    "Parameters": [
        {"ParameterKey": "EnvironmentName", "DefaultValue": "prod"},
        {"ParameterKey": "VpcCIDR", "DefaultValue": "10.0.0.0/16"},
        ...
    ],
    "Description": "Production 3-Tier Web Application"
}

Step 3 — Deploy the Stack

# Create the stack
$ aws cloudformation create-stack \
    --stack-name prod-3tier-app \
    --template-body file://three-tier-app.yaml \
    --parameters \
        ParameterKey=EnvironmentName,ParameterValue=prod \
        ParameterKey=InstanceType,ParameterValue=t3.micro \
        ParameterKey=DBMasterUsername,ParameterValue=admin \
        ParameterKey=DBMasterPassword,ParameterValue=MySecureP@ss123 \
        ParameterKey=KeyPairName,ParameterValue=my-key-pair \
    --capabilities CAPABILITY_NAMED_IAM \
    --tags Key=Project,Value=practice-4
{
    "StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/prod-3tier-app/..."
}

# Monitor stack creation (takes 10-15 minutes)
$ aws cloudformation wait stack-create-complete --stack-name prod-3tier-app

# Check stack events for progress
$ aws cloudformation describe-stack-events \
    --stack-name prod-3tier-app \
    --query 'StackEvents[0:5].{Resource:LogicalResourceId,Status:ResourceStatus,Time:Timestamp}' \
    --output table

Step 4 — Verify the Deployment

# Get stack outputs
$ aws cloudformation describe-stacks \
    --stack-name prod-3tier-app \
    --query 'Stacks[0].Outputs'
[
    {
        "OutputKey": "ALBUrl",
        "OutputValue": "http://prod-alb-123456.us-east-1.elb.amazonaws.com"
    },
    {
        "OutputKey": "DatabaseEndpoint",
        "OutputValue": "prod-db.abc123.us-east-1.rds.amazonaws.com"
    }
]

# Test the application
$ curl http://prod-alb-123456.us-east-1.elb.amazonaws.com/
<!DOCTYPE html>
<html>
<head><title>3-Tier App</title></head>
<body>
<h1>3-Tier Application</h1>
<p>Environment: prod</p>
<p>Instance: i-0abc123</p>
<p>AZ: us-east-1a</p>
</body>
</html>

# Verify Auto Scaling
$ aws autoscaling describe-auto-scaling-groups \
    --auto-scaling-group-names prod-asg \
    --query 'AutoScalingGroups[0].{Min:MinSize,Max:MaxSize,Desired:DesiredCapacity,Instances:Instances[].InstanceId}'
{
    "Min": 2,
    "Max": 4,
    "Desired": 2,
    "Instances": ["i-0abc123", "i-0def456"]
}

# Verify RDS
$ aws rds describe-db-instances \
    --db-instance-identifier prod-db \
    --query 'DBInstances[0].{Status:DBInstanceStatus,Engine:Engine,Endpoint:Endpoint.Address}'
{
    "Status": "available",
    "Engine": "postgres",
    "Endpoint": "prod-db.abc123.us-east-1.rds.amazonaws.com"
}

Step 5 — Update the Stack

# Update instance type (zero-downtime with rolling update)
$ aws cloudformation update-stack \
    --stack-name prod-3tier-app \
    --template-body file://three-tier-app.yaml \
    --parameters \
        ParameterKey=EnvironmentName,UsePreviousValue=true \
        ParameterKey=InstanceType,ParameterValue=t3.small \
        ParameterKey=DBMasterUsername,UsePreviousValue=true \
        ParameterKey=DBMasterPassword,UsePreviousValue=true \
        ParameterKey=KeyPairName,UsePreviousValue=true \
    --capabilities CAPABILITY_NAMED_IAM

# Monitor the update
$ aws cloudformation describe-stack-events \
    --stack-name prod-3tier-app \
    --query 'StackEvents[?ResourceStatus==`UPDATE_COMPLETE`].LogicalResourceId'

Step 6 — Clean Up

# Delete the entire stack (all resources)
$ aws cloudformation delete-stack --stack-name prod-3tier-app

# Wait for deletion
$ aws cloudformation wait stack-delete-complete --stack-name prod-3tier-app

# Verify everything is gone
$ aws cloudformation describe-stacks --stack-name prod-3tier-app
An error occurred (ValidationError): Stack with id prod-3tier-app does not exist

That’s the power of CloudFormation — one command to create everything, one command to destroy everything. No orphaned resources.


What You Built

Component Count CloudFormation Resource
VPC 1 AWS::EC2::VPC
Subnets 6 AWS::EC2::Subnet
Internet Gateway 1 AWS::EC2::InternetGateway
NAT Gateway 1 AWS::EC2::NatGateway
Route Tables 2 AWS::EC2::RouteTable
Security Groups 3 AWS::EC2::SecurityGroup
ALB 1 AWS::ElasticLoadBalancingV2::LoadBalancer
Target Group 1 AWS::ElasticLoadBalancingV2::TargetGroup
Launch Template 1 AWS::EC2::LaunchTemplate
Auto Scaling Group 1 AWS::AutoScaling::AutoScalingGroup
RDS Instance 1 AWS::RDS::DBInstance
IAM Role 1 AWS::IAM::Role
Total Resources ~20 1 template file

Common Mistakes

Mistake Impact Fix
Missing CAPABILITY_NAMED_IAM Stack creation fails Add --capabilities CAPABILITY_NAMED_IAM
Hardcoded AZ names Fails in other regions Use !Select [0, !GetAZs '']
No DeletionPolicy on RDS Data lost on stack delete Add DeletionPolicy: Snapshot for production
Circular security group refs Template validation error Use SecurityGroupIngress resource type
Forgetting DependsOn Resources created out of order CloudFormation handles most deps; add explicit ones for edge cases

Next → Project 5: Monitoring & Alerting System

Updated: