AWS for DevOps — Project 4: CloudFormation 3-Tier Application
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 |