Deploy a Two-Tier Web App with CloudFormation
Deploy a VPC, EC2, RDS, and ALB stack with one CloudFormation command.
Introduction
30 Second Summary
Every time you click through the AWS console to create a server, a database, and a load balancer, you are doing work that a single file could do for you in minutes. Infrastructure as Code means your entire architecture lives in one template that you can deploy, tear down, and redeploy on demand.
In this project, you will write a single CloudFormation YAML template that deploys a complete two-tier web application on AWS. One command brings everything up. One command tears it all down.
What You'll Build
When you paste the load balancer's DNS name into your browser, you see a live web page showing data pulled from your private MySQL database and the hostname of the server that handled your request. Refresh the page and watch it rotate between your two servers.
By the end of this project, you'll have:
- A shareable URL from your Application Load Balancer that anyone can open to see your live app pulling data from a private RDS MySQL database.
- A load-balanced demo where refreshing the page visibly rotates between two EC2 instances, proving traffic is distributed across your servers.
- A security group chain you diagnosed and fixed yourself when the load balancer returned a 502 error.
- Secret Mission: Replace the two fixed EC2 instances with an Auto Scaling Group that automatically replaces failed servers for true self-healing infrastructure.
Are there any prerequisites?
You need an AWS account on the legacy Free Tier (created before July 15, 2025) and the AWS CLI installed and configured on your machine. No other tools are required because CloudFormation runs entirely through the CLI you already have.
Before We Start
Before we dive in, let's lock in what you're about to build and why deploying infrastructure through code matters more than clicking through a console.
Set Up Your Environment
CloudFormation will handle all the infrastructure provisioning for you, but it needs a few things in place first. Your AWS CLI must be able to authenticate with your account, and you need a key pair so CloudFormation can launch EC2 instances you can SSH into later.
In this step, you will confirm your CLI is working, create an EC2 key pair, and set up the project folder where your entire infrastructure definition will live in a single file.
In this step, get ready to:
- Verify your AWS CLI can authenticate with your account.
- Create an EC2 key pair for instance access.
- Set up your project folder and template file.
Verify your AWS CLI configuration
Before CloudFormation can create anything on your behalf, the CLI needs valid credentials tied to your AWS account.
- Confirm your CLI is authenticated by running this command:
aws sts get-caller-identity
Before you run this, what do you expect to see if your CLI is properly configured?
You should see JSON output containing your Account ID, user ARN, and UserId.
✔️ I see my Account ID and ARN
Your CLI is authenticated and ready to go.
ⓧ I see an error about credentials
Your CLI is not configured yet.
- Run the following command to configure your credentials:
aws configure
- Enter your AWS Access Key ID when prompted.
- Enter your Secret Access Key when prompted.
- Enter your preferred region (e.g. us-east-1).
- Press Enter for the default output format.
- Run aws sts get-caller-identity again to confirm it now works.
ⓧ Command not found
The AWS CLI is not installed on your machine.
- Visit the AWS CLI installation guide and follow the instructions for your operating system.
- After installing, close and reopen your terminal.
- Run aws sts get-caller-identity again to confirm it works.
Create your project folder and EC2 key pair
An EC2 key pair lets you SSH into your instances for debugging if something goes wrong during deployment. You will also create the project folder where all your files will live.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir two-tier-app
cd two-tier-app
- Create the EC2 key pair and save it locally by running the command for your operating system:
macOS
aws ec2 create-key-pair --key-name two-tier-key --key-type rsa --key-format pem --query "KeyMaterial" --output text > two-tier-key.pem
chmod 400 two-tier-key.pem
What do these commands do?
- The first command asks AWS to generate a key pair named two-tier-key and saves the private key to a local file.
- chmod 400 restricts the file so only you can read it. SSH refuses to use key files with open permissions.
Windows
aws ec2 create-key-pair --key-name two-tier-key --key-type rsa --key-format pem --query "KeyMaterial" --output text | Out-File -Encoding ascii -FilePath two-tier-key.pem
What does this command do?
This asks AWS to generate a key pair named two-tier-key and pipes the private key material into a local .pem file using PowerShell's Out-File command.
- Confirm the key file was created by running:
ls
You should see two-tier-key.pem listed in the output.
Seeing an error about the key pair?
- If you see InvalidKeyPair.Duplicate, a key pair named two-tier-key already exists in your account. Delete it first with aws ec2 delete-key-pair --key-name two-tier-key and try again.
- If you see a credentials error, revisit the CLI verification substep above.
help me fix my EC2 key pair creation error
Create the CloudFormation template file
Your entire two-tier infrastructure will be defined in a single YAML file. CloudFormation reads this file and provisions every resource in it automatically.
- Create the empty template file by running:
macOS
touch two-tier-stack.yaml
Windows
New-Item two-tier-stack.yaml
- Confirm both files exist by running:
ls
You should see both two-tier-key.pem and two-tier-stack.yaml listed.
- Open the project folder in VS Code by running:
code .
VS Code not opening from terminal?
If code . does not work, open VS Code manually.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter.
- Click File in the top menu bar, then Open Folder.
- Navigate to your Desktop, select the two-tier-app folder, and click Open.
In the VS Code Explorer sidebar, you should see two-tier-stack.yaml listed. This empty file is where your entire infrastructure definition will go in the next step.
Don't see the file in VS Code?
- Make sure you opened the two-tier-app folder, not your Desktop or home folder.
- Check that the Explorer sidebar is visible. Toggle it with Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows).
help me open my project folder in VS Code
Your environment is ready. Next up, you will write the networking foundation of your two-tier architecture and deploy it as a live CloudFormation stack.
Deploy the Networking Foundation
In the previous step, you created a key pair and an empty template file. Now it is time to fill that template with the network skeleton your entire application relies on.
Every production app needs isolated network boundaries. Without a custom VPC, your database would sit on the public internet where anyone could reach it. This step creates the separation between your public-facing web tier and your private database tier.
In this step, get ready to:
- Define a CloudFormation template with parameters, a VPC, subnets, routing, and security groups.
- Validate the template syntax and deploy it as a stack.
- Verify the stack created all networking resources successfully.
Define the template header and VPC core
A CloudFormation template starts with a format version, a description, and parameters that let you pass values at deploy time.
- Switch back to two-tier-stack.yaml in VS Code and paste the following template header:
AWSTemplateFormatVersion: '2010-09-09'
Description: Two-Tier Web Application with VPC, EC2, RDS, and ALB
Parameters:
DBUsername:
Type: String
Default: admin
Description: Database admin username
DBPassword:
Type: String
NoEcho: true
MinLength: 8
Description: Database admin password
KeyPairName:
Type: String
Default: two-tier-key
Description: EC2 Key Pair name for SSH access
What does this code do?
The Parameters section defines inputs you can supply at deploy time. DBPassword uses NoEcho: true so it never appears in console output or logs.
KeyPairName defaults to two-tier-key, the key pair you created in the previous step. This keeps your deploy command short.
- Below the Parameters section, paste the VPC and Internet Gateway resources:
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsSupport: true
EnableDnsHostnames: true
Tags:
- Key: Name
Value: TwoTier-VPC
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: TwoTier-IGW
AttachGateway:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref VPC
InternetGatewayId: !Ref InternetGateway
What does this code do?
The VPC creates an isolated network with a 10.0.0.0/16 address space (65,536 IP addresses). EnableDnsHostnames lets instances receive public DNS names.
The Internet Gateway is the VPC's door to the public internet. AttachGateway bolts it onto the VPC so traffic can flow in and out.
- Save two-tier-stack.yaml.
- Confirm your file now has about 40 lines. You should see the Resources: keyword with VPC, InternetGateway, and AttachGateway beneath it.
Add subnets across two Availability Zones
Subnets divide your VPC into smaller segments. Public subnets get internet access for your web servers. Private subnets stay hidden for your database.
- Below the AttachGateway resource in two-tier-stack.yaml, paste the two 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: TwoTier-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: TwoTier-Public-2
Why two subnets in different Availability Zones?
Each Availability Zone is a physically separate data centre. Spreading subnets across two AZs means your app survives if one data centre goes offline.
MapPublicIpOnLaunch: true gives instances in these subnets a public IP automatically. Your web servers need this to receive traffic from the internet.
- Below the public subnets, paste the two private subnets:
PrivateSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.3.0/24
AvailabilityZone: !Select [0, !GetAZs '']
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: TwoTier-Private-1
PrivateSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.4.0/24
AvailabilityZone: !Select [1, !GetAZs '']
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: TwoTier-Private-2
What makes these subnets private?
MapPublicIpOnLaunch: false means instances here get no public IP. Combined with no route to the Internet Gateway, they are completely unreachable from outside. Your RDS database will live here.
- Save two-tier-stack.yaml.
- Your file should now be around 82 lines. Scroll through and confirm you see four subnet resources: PublicSubnet1, PublicSubnet2, PrivateSubnet1, PrivateSubnet2.
YAML looking broken in the editor?
YAML is whitespace-sensitive. Every resource name (like PublicSubnet1) must be indented with exactly 2 spaces under Resources:. Properties sit 2 more spaces deeper. If your editor shows red squiggles, check that you have not mixed tabs and spaces.
help me fix YAML indentation errors in my CloudFormation template
Add routing, security groups, and deploy the stack
Subnets alone cannot reach the internet. A route table tells traffic where to go. Security groups then control which traffic is allowed through.
- Below PrivateSubnet2 in two-tier-stack.yaml, paste the route table and its associations:
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: TwoTier-Public-RT
PublicRoute:
Type: AWS::EC2::Route
DependsOn: AttachGateway
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnet1RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet1
RouteTableId: !Ref PublicRouteTable
PublicSubnet2RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet2
RouteTableId: !Ref PublicRouteTable
What does this code do?
PublicRoute sends all outbound traffic (0.0.0.0/0) to the Internet Gateway. The two association resources attach this route table to both public subnets, giving them internet access.
DependsOn: AttachGateway ensures CloudFormation waits until the Internet Gateway is attached before creating the route. Without this, the route could fail because the gateway is not ready yet.
- Below the route table associations, paste the ALB and EC2 security groups:
ALBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow HTTP from internet to ALB
VpcId: !Ref VPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
Tags:
- Key: Name
Value: TwoTier-ALB-SG
EC2SecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow HTTP from ALB only
VpcId: !Ref VPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !GetAtt ALBSecurityGroup.GroupId
Tags:
- Key: Name
Value: TwoTier-EC2-SG
What is a security group chain?
Security groups act as firewalls around your resources. Instead of allowing traffic from everywhere, the EC2SecurityGroup only accepts traffic from the ALBSecurityGroup. This means only the load balancer can talk to your web servers.
This chain pattern (internet to ALB to EC2 to DB) is a core production security practice. Each layer trusts only the layer directly in front of it.
- Finally, paste the database security group below EC2SecurityGroup:
DBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow MySQL from EC2 instances only
VpcId: !Ref VPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 3306
ToPort: 3306
SourceSecurityGroupId: !GetAtt EC2SecurityGroup.GroupId
Tags:
- Key: Name
Value: TwoTier-DB-SG
Why port 3306?
Port 3306 is MySQL's default port. This rule means only your EC2 instances (identified by their security group) can reach the database. Nothing else in the VPC or on the internet can connect to it.
- Save two-tier-stack.yaml.
Your template is now complete. Before you deploy, validate it to catch any syntax errors.
- Validate the template syntax by running this command in your terminal:
aws cloudformation validate-template --template-body file://two-tier-stack.yaml
- You should see a JSON response containing your three parameters (DBUsername, DBPassword, KeyPairName). This confirms CloudFormation can parse the template.
Seeing a validation error?
The most common cause is a YAML indentation mistake. Check that every resource name lines up at 2 spaces, and every Properties: key is 4 spaces in. Also confirm you have no trailing whitespace or tab characters.
help me debug a CloudFormation validate-template error
What do you think will happen when you deploy this template? Will CloudFormation create all the resources instantly, or will it take time?
- Deploy the networking stack by running:
aws cloudformation create-stack --stack-name two-tier-network --template-body file://two-tier-stack.yaml
- You should see a JSON response with a StackId ARN. This means CloudFormation accepted your template and started provisioning resources.
What happens when you create a stack?
CloudFormation reads your template, figures out the dependency order (IGW before route, VPC before subnets), and provisions resources in parallel where possible. The entire network takes about 1-2 minutes to come up.
- Wait for the stack to finish creating by running:
aws cloudformation wait stack-create-complete --stack-name two-tier-network
This command blocks until the stack reaches CREATE_COMPLETE. No output means success.
- Verify the stack status by running:
aws cloudformation describe-stacks --stack-name two-tier-network --query "Stacks[0].StackStatus"
- You should see "CREATE_COMPLETE" in the output.
- Confirm the VPC exists by running:
aws ec2 describe-vpcs --filters "Name=tag:Name,Values=TwoTier-VPC" --query "Vpcs[0].VpcId"
- You should see a VPC ID like "vpc-0abc123def456" returned. This proves CloudFormation created your custom VPC from the template.
Stack stuck or showing ROLLBACK_COMPLETE?
If the stack rolled back, check the events to find the failure cause:
aws cloudformation describe-stack-events --stack-name two-tier-network --query "StackEvents[?ResourceStatus=='CREATE_FAILED']"
Common causes include insufficient IAM permissions or a region that does not have enough Availability Zones. Confirm your AWS CLI is configured for a region with at least 2 AZs (most regions qualify).
help me debug a CloudFormation stack rollback
✔️ Awesome, I've got everything!
Great. Double check you have saved two-tier-stack.yaml and your stack shows CREATE_COMPLETE.
ⓧ I'd like to double check the full code
Your two-tier-stack.yaml should contain these sections in order. Scroll through your file and confirm each one is present:
- AWSTemplateFormatVersion and Description at the top.
- Parameters section with DBUsername, DBPassword, and KeyPairName.
- Resources section starting with VPC, InternetGateway, AttachGateway.
- Four subnets: PublicSubnet1, PublicSubnet2, PrivateSubnet1, PrivateSubnet2.
- PublicRouteTable, PublicRoute, and two route table associations.
- Three security groups: ALBSecurityGroup, EC2SecurityGroup, DBSecurityGroup.
Your entire network foundation is now live. A custom VPC with four subnets, internet routing, and a layered security chain. All created from a single YAML file. Next up, you will add the actual workload: a MySQL database and Node.js web servers.
Add the Database and Application Layers
Your network skeleton is in place. Public and private subnets are separated across two Availability Zones, and security groups enforce a strict trust chain.
Now you need the actual workload. An RDS MySQL database goes into the private subnets where it is unreachable from the internet. Two EC2 instances go into the public subnets, each running a Node.js app that is bootstrapped automatically through UserData.
In this step, get ready to:
- Add an RDS MySQL database isolated in private subnets.
- Deploy two EC2 instances with a Node.js app bootstrapped via UserData.
- Update the stack and verify the app serves data from MySQL.
Add the RDS MySQL database
Your database needs to live in a private subnet group so CloudFormation knows which subnets to place it in. The RDS instance itself uses the DBSecurityGroup you created earlier, which only allows MySQL traffic (port 3306) from your EC2 instances.
- Switch back to two-tier-stack.yaml in VS Code.
- Scroll to the bottom of the Resources section (after the DBSecurityGroup resource) and paste the following:
# --- RDS Database ---
DBSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: Private subnets for RDS
SubnetIds:
- !Ref PrivateSubnet1
- !Ref PrivateSubnet2
Database:
Type: AWS::RDS::DBInstance
Properties:
DBInstanceIdentifier: twotier-db
DBInstanceClass: db.t3.micro
Engine: mysql
EngineVersion: '8.0'
AllocatedStorage: 20
StorageType: gp2
DBName: appdb
MasterUsername: !Ref DBUsername
MasterUserPassword: !Ref DBPassword
DBSubnetGroupName: !Ref DBSubnetGroup
VPCSecurityGroups:
- !GetAtt DBSecurityGroup.GroupId
MultiAZ: false
PubliclyAccessible: false
BackupRetentionPeriod: 0
DeletionPolicy: Delete
What does this code do?
The DBSubnetGroup tells RDS which subnets are available for the database. By referencing only the private subnets, your database can never receive a public IP.
The Database resource provisions a MySQL 8.0 instance on the smallest tier (db.t3.micro). The master credentials come from the template parameters you defined earlier. DeletionPolicy: Delete ensures CloudFormation removes the DB when you delete the stack.
- Save two-tier-stack.yaml.
- Validate the template syntax by running:
aws cloudformation validate-template --template-body file://two-tier-stack.yaml
You should see a JSON response listing your three parameters (DBUsername, DBPassword, KeyPairName). This confirms the YAML is syntactically valid.
Seeing a template format error?
YAML is indentation-sensitive. Make sure DBSubnetGroup and Database are indented at the same level as your other resources (two spaces from the left margin of Resources). Check for any tabs mixed with spaces.
Help me debug my CloudFormation template validation error
Add the EC2 web server instances
Each EC2 instance runs a Node.js Express app that connects to the RDS database, records visitor data, and displays it. The UserData script installs dependencies and starts the server automatically at boot time.
- Below the Database resource you just added, paste the resource definition and beginning of the UserData script:
# --- EC2 Instances ---
WebServer1:
Type: AWS::EC2::Instance
DependsOn: Database
Properties:
InstanceType: t3.micro
ImageId: !Sub '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}'
KeyName: !Ref KeyPairName
SubnetId: !Ref PublicSubnet1
SecurityGroupIds:
- !GetAtt EC2SecurityGroup.GroupId
Tags:
- Key: Name
Value: TwoTier-Web-1
UserData:
Fn::Base64: !Sub |
#!/bin/bash
dnf install -y nodejs22 nodejs22-npm mysql
alternatives --set node /usr/bin/node-22
alternatives --set npm /usr/bin/npm-22
mkdir -p /home/ec2-user/app
cd /home/ec2-user/app
What is happening in this resource definition?
The DependsOn: Database ensures CloudFormation waits until the RDS instance is fully available before launching EC2 instances. This prevents the app from trying to connect to a database that does not exist yet.
The ImageId uses an SSM parameter that always resolves to the latest Amazon Linux 2023 AMI. The bash script installs Node.js 22, sets it as the default via alternatives, then creates and enters the app directory.
- Immediately below (continuing the UserData block at the same indentation), paste the dependency setup:
cat > package.json << 'PKGJSON'
{
"name": "two-tier-app",
"version": "1.0.0",
"dependencies": {
"express": "^4.21.0",
"mysql2": "^3.11.0"
}
}
PKGJSON
npm install
- Continue pasting the server application code directly below:
cat > server.js << 'SERVERJS'
const express = require('express');
const mysql = require('mysql2/promise');
const os = require('os');
const app = express();
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME
};
async function initDB() {
const conn = await mysql.createConnection(dbConfig);
await conn.execute(`CREATE TABLE IF NOT EXISTS visits (
id INT AUTO_INCREMENT PRIMARY KEY,
hostname VARCHAR(255),
visited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`);
await conn.end();
}
- Paste the route handler, health endpoint, and startup commands to finish the UserData:
app.get('/', async (req, res) => {
try {
const conn = await mysql.createConnection(dbConfig);
const hostname = os.hostname();
await conn.execute('INSERT INTO visits (hostname) VALUES (?)', [hostname]);
const [rows] = await conn.execute('SELECT * FROM visits ORDER BY visited_at DESC LIMIT 10');
await conn.end();
let html = `<h1>Two-Tier App</h1>`;
html += `<p><strong>Served by:</strong> ${hostname}</p>`;
html += `<h2>Recent Visits</h2><ul>`;
rows.forEach(r => { html += `<li>${r.hostname} at ${r.visited_at}</li>`; });
html += `</ul>`;
res.send(html);
} catch (err) {
res.status(500).send(`Error: ${err.message}`);
}
});
app.get('/health', (req, res) => res.send('OK'));
initDB().then(() => {
app.listen(80, () => console.log('Server running on port 80'));
});
SERVERJS
export DB_HOST="${Database.Endpoint.Address}"
export DB_USER="${DBUsername}"
export DB_PASS="${DBPassword}"
export DB_NAME="appdb"
node server.js &
What does the Express app do?
On each request to /, the app inserts the instance's hostname into a visits table, then queries the last 10 visits and renders them as HTML. This proves the EC2-to-RDS connection works and later lets you see which instance served each request.
The ${Database.Endpoint.Address} syntax is CloudFormation's !Sub at work. It injects the RDS endpoint into the environment variable at launch time, so the app always connects to the correct database.
- Now add WebServer2. It is identical to WebServer1 except it goes into PublicSubnet2 and is tagged TwoTier-Web-2. Copy the entire WebServer1 block you just pasted, paste it directly below, and make these three changes:
- Change the resource name from WebServer1: to WebServer2:.
- Change SubnetId: !Ref PublicSubnet1 to SubnetId: !Ref PublicSubnet2.
- Change Value: TwoTier-Web-1 to Value: TwoTier-Web-2.
- Save two-tier-stack.yaml and validate the template again:
aws cloudformation validate-template --template-body file://two-tier-stack.yaml
You should see the same parameters list as before. If validation passes, your template now defines the full database and application layers.
Validation fails after adding WebServer2?
The most common cause is a duplicate key. Make sure you renamed WebServer1: to WebServer2: at the top of the copied block. YAML does not allow two resources with the same name.
Also check that the indentation of the pasted block matches WebServer1 exactly. Every line should align with its counterpart above.
Help me fix my CloudFormation template after adding WebServer2
Deploy the update and verify
Your template now defines both the database and application layers. Time to push the update to CloudFormation so it provisions these new resources inside your existing VPC.
Why does this update take several minutes?
RDS database creation is the slowest part. CloudFormation must provision the MySQL instance, set up storage, and wait for the database to become available. Expect 5 to 10 minutes for this step. The EC2 instances are blocked by DependsOn: Database, so they launch only after RDS is ready.
- Update the stack by running this command (choose a password of at least 8 characters for the database):
aws cloudformation update-stack --stack-name two-tier-network --template-body file://two-tier-stack.yaml --parameters ParameterKey=DBPassword,ParameterValue=YourSecurePass1
What does the --parameters flag do?
The DBPassword parameter was defined in your template with NoEcho: true. CloudFormation never shows NoEcho values in the console or CLI output. You must supply it explicitly on every update. The other parameters (DBUsername, KeyPairName) use their template defaults.
- Wait for the update to finish by running:
aws cloudformation wait stack-update-complete --stack-name two-tier-network
This command blocks your terminal until the stack reaches UPDATE_COMPLETE. It polls every 30 seconds. Go grab a coffee while RDS provisions.
- Once the command returns, verify the stack status by running:
aws cloudformation describe-stacks --stack-name two-tier-network --query "Stacks[0].StackStatus"
Before you check the output, what status do you expect to see?
You should see "UPDATE_COMPLETE". This confirms all resources (VPC, subnets, security groups, RDS, and both EC2 instances) are now live.
- Get the public IP of one of your web servers by running:
aws cloudformation describe-stacks --stack-name two-tier-network --query "Stacks[0].Outputs[?OutputKey=='WebServer1PublicIP'].OutputValue" --output text
- Copy the IP address from the output and open it in your browser on port 80 (for example, http://11.22.33.44).
You should see a page titled Two-Tier App showing the serving hostname and a list of recent visits pulled from MySQL.
Page not loading in the browser?
The UserData script takes 1 to 2 minutes after the instance launches to install Node.js and start the server. Wait a moment and try again.
If it still does not load, confirm the stack outputs include an IP. Run aws cloudformation describe-stacks --stack-name two-tier-network --query "Stacks[0].Outputs" and check that WebServer1PublicIP is present with a valid address.
My EC2 instance is not serving the Node.js app on port 80
Why can you access the instance directly if the security group only allows traffic from the ALB?
The EC2SecurityGroup allows inbound HTTP only from the ALBSecurityGroup. Direct browser access works right now because the instances have public IPs and the ALB does not yet exist as a traffic source. Some configurations and network paths may still allow the connection through. When you add the ALB in the next step, this security group chain becomes critical.
✔️ Awesome, I've got everything!
Great. Double check you have saved two-tier-stack.yaml and that your stack status shows UPDATE_COMPLETE.
ⓧ I'd like to double check the full code
Your two-tier-stack.yaml is now quite long. Scroll through and confirm these resource blocks are present in this order:
- Parameters (DBUsername, DBPassword, KeyPairName).
- VPC, InternetGateway, AttachGateway.
- PublicSubnet1, PublicSubnet2, PrivateSubnet1, PrivateSubnet2.
- PublicRouteTable, PublicRoute, two route table associations.
- ALBSecurityGroup, EC2SecurityGroup, DBSecurityGroup.
- DBSubnetGroup, Database.
- WebServer1 (SubnetId: PublicSubnet1, Tag: TwoTier-Web-1).
- WebServer2 (SubnetId: PublicSubnet2, Tag: TwoTier-Web-2).
Both WebServer resources should have identical UserData scripts. The only differences are the SubnetId reference and the Name tag value.
Your two-tier architecture is taking shape. You have a private database, two application servers, and proof that they talk to each other. Next up, you will add a load balancer to distribute traffic across both instances and hit a security group puzzle that will test your debugging skills.
Wire Up the Load Balancer and Fix the 502
Your EC2 instances are running and serving the app, but users need a specific IP address to reach each one. That is fragile and unscalable.
An Application Load Balancer gives you a single DNS name that distributes traffic across both instances automatically. In this step, you will add the ALB to your template, deploy it, and diagnose the 502 that appears while health checks are still running.
In this step, get ready to:
- Add the ALB, target group, and listener to your CloudFormation template.
- Deploy the update and diagnose the initial 502 error.
- Confirm traffic rotation between your two EC2 instances.
Add the load balancer resource
The ALB sits in both public subnets and accepts HTTP traffic from the internet. It uses the ALBSecurityGroup you created earlier, which allows inbound port 80 from anywhere.
- In VS Code, open two-tier-stack.yaml.
- Scroll to the bottom of the file, below the WebServer2 resource.
- Add the ALB resource by pasting this code:
# --- Application Load Balancer ---
ALB:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: TwoTier-ALB
Scheme: internet-facing
Type: application
Subnets:
- !Ref PublicSubnet1
- !Ref PublicSubnet2
SecurityGroups:
- !GetAtt ALBSecurityGroup.GroupId
What does this resource do?
This creates an internet-facing Application Load Balancer that lives in both public subnets. The Scheme: internet-facing means it gets a public DNS name anyone can reach.
Placing it in two subnets (across two Availability Zones) means the ALB stays available even if one AZ has issues.
Add the target group and listener
The ALB needs to know where to send traffic. A target group defines the destination instances, and a listener tells the ALB which port to accept requests on.
- Directly below the ALB resource, add the target group:
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: TwoTier-TG
Port: 80
Protocol: HTTP
VpcId: !Ref VPC
HealthCheckPath: /health
HealthCheckIntervalSeconds: 30
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
Targets:
- Id: !Ref WebServer1
Port: 80
- Id: !Ref WebServer2
Port: 80
What does the health check do?
The ALB pings /health on each instance every 30 seconds. An instance must respond successfully twice in a row (HealthyThresholdCount: 2) to be considered healthy.
If an instance fails three consecutive checks (UnhealthyThresholdCount: 3), the ALB stops sending it traffic. Your Express app already exposes a /health endpoint that returns OK.
- Validate your template is still syntactically correct by running:
aws cloudformation validate-template --template-body file://two-tier-stack.yaml
You should see a JSON response listing your parameters. If you see an error, check for YAML indentation issues.
- Now add the listener below the target group:
ALBListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref ALB
Port: 80
Protocol: HTTP
DefaultActions:
- Type: forward
TargetGroupArn: !Ref TargetGroup
What does the listener do?
The listener watches for HTTP requests on port 80 and forwards them to the target group. The target group then picks one of the healthy instances to handle the request.
Add the Outputs section
Stack outputs make it easy to retrieve key values (like the ALB DNS name) without digging through the AWS console.
- After the ALBListener resource, add the Outputs section at the same indentation level as Resources: (no indent):
Outputs:
VPCID:
Description: VPC ID
Value: !Ref VPC
ALBDNSName:
Description: ALB DNS Name
Value: !GetAtt ALB.DNSName
RDSEndpoint:
Description: RDS Endpoint
Value: !GetAtt Database.Endpoint.Address
WebServer1PublicIP:
Description: Web Server 1 Public IP
Value: !GetAtt WebServer1.PublicIp
WebServer2PublicIP:
Description: Web Server 2 Public IP
Value: !GetAtt WebServer2.PublicIp
What does this section do?
The Outputs section exposes values from your stack that you can query with the CLI. The ALBDNSName output is the one you will use to access your app in the browser.
- Save two-tier-stack.yaml.
✔️ Awesome, I've got everything!
Great. Confirm you have saved the file before deploying.
ⓧ I'd like to double check the full code
Your template is now large, so confirm these sections are present in order:
- Parameters (DBUsername, DBPassword, KeyPairName).
- Resources: VPC, InternetGateway, AttachGateway, PublicSubnet1, PublicSubnet2, PrivateSubnet1, PrivateSubnet2, PublicRouteTable, PublicRoute, two route table associations, ALBSecurityGroup, EC2SecurityGroup, DBSecurityGroup, DBSubnetGroup, Database, WebServer1, WebServer2, ALB, TargetGroup, ALBListener.
- Outputs: VPCID, ALBDNSName, RDSEndpoint, WebServer1PublicIP, WebServer2PublicIP.
If anything is missing, scroll up and copy the relevant chunk.
Seeing a validation error?
YAML indentation is the most common culprit. Each resource must be indented exactly 2 spaces under Resources:. The Outputs: key must have zero indentation (same level as Resources:).
help me fix a CloudFormation YAML indentation error
Deploy the update and diagnose the 502
Time to deploy the ALB. The stack update will add the load balancer, target group, and listener to your running infrastructure.
- Deploy the updated template by running:
aws cloudformation update-stack --stack-name two-tier-network --template-body file://two-tier-stack.yaml --parameters ParameterKey=DBPassword,ParameterValue=YourSecurePass1
- Wait for the update to finish:
aws cloudformation wait stack-update-complete --stack-name two-tier-network
This takes 2-3 minutes while CloudFormation provisions the ALB.
- Once complete, get the ALB DNS name by running:
aws cloudformation describe-stacks --stack-name two-tier-network --query "Stacks[0].Outputs[?OutputKey=='ALBDNSName'].OutputValue" --output text
You should see a DNS name like TwoTier-ALB-123456789.us-east-1.elb.amazonaws.com.
Before you open this URL in your browser, what do you expect to see?
- Copy the ALB DNS name and paste it into your browser.
You see a 502 Bad Gateway error. This is expected and intentional.
Why am I seeing a 502?
The ALB just started running health checks against your EC2 instances. Until an instance passes two consecutive checks (at 30-second intervals), the ALB considers it unhealthy and returns a 502 to any incoming request.
This is the security group trust chain in action. The EC2SecurityGroup only allows HTTP from the ALBSecurityGroup. The health check traffic comes from the ALB, so it must pass through that rule.
- Confirm the targets are still unhealthy by checking the target health. First, get the target group ARN:
aws elbv2 describe-target-groups --names TwoTier-TG --query "TargetGroups[0].TargetGroupArn" --output text
- Copy the ARN from the output, then check the health of each target:
aws elbv2 describe-target-health --target-group-arn [[TG_ARN="paste your target group ARN here"]]
You should see both targets with a state of initial or unhealthy. This confirms the health checks have not yet passed.
- Verify your EC2SecurityGroup inbound rule is correct. Open two-tier-stack.yaml and find the EC2SecurityGroup resource. Confirm the ingress rule says:
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !GetAtt ALBSecurityGroup.GroupId
If this line says CidrIp: 0.0.0.0/0 instead of SourceSecurityGroupId, change it back to the version above and run another stack update. Only ALB traffic should reach your instances.
What is the security group trust chain?
Traffic flows through three security groups in sequence: internet to ALB (ALBSecurityGroup allows port 80 from 0.0.0.0/0), ALB to EC2 (EC2SecurityGroup allows port 80 from ALBSecurityGroup), and EC2 to RDS (DBSecurityGroup allows port 3306 from EC2SecurityGroup).
Each layer only trusts the layer directly above it. This means the database is completely unreachable from the internet.
Confirm traffic rotation
The health checks pass after about 60 seconds (two successful checks at 30-second intervals).
- Wait about 60 seconds from when the stack update completed.
- Run the target health check again to confirm both targets are now healthy:
aws elbv2 describe-target-health --target-group-arn [[TG_ARN="paste your target group ARN here"]]
Both targets should now show a state of healthy.
- Refresh the ALB DNS URL in your browser.
You should see your app showing Two-Tier App, the serving hostname, and recent visits pulled from MySQL.
- Refresh the page several times. Watch the Served by: hostname alternate between your two instances.
Each refresh may land on a different instance. The ALB is distributing traffic across both.
Still seeing a 502 after 60 seconds?
If targets remain unhealthy, the Node.js app may not have finished starting on the instances. UserData scripts can take a few minutes to install packages and start the server.
Check that your EC2SecurityGroup uses SourceSecurityGroupId: !GetAtt ALBSecurityGroup.GroupId and not CidrIp: 0.0.0.0/0. If you changed it for direct testing in the previous step, revert it and run another stack update.
help me debug why my ALB targets are staying unhealthy
Your load balancer is live. One DNS name, two instances, automatic traffic distribution, and a security group chain that keeps your database completely hidden from the internet. Next up, you will clean up all these resources with a single command.
Secret mission
Replace EC2 Instances with an Auto Scaling Group
Your two EC2 instances work, but they are fragile. If one dies, you lose half your capacity until you manually intervene. In this bonus challenge, you refactor your CloudFormation template to use an Auto Scaling Group that replaces failed instances automatically.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project provisions AWS resources that consume Free Tier hours while running.
Cost warning
If left running, the ALB and RDS instance will consume Free Tier hours. Two t3.micro instances together use your 750 hours twice as fast, giving you roughly 375 hours (about 15 days) of runway per month before exceeding limits.
Deleting the CloudFormation stack removes all resources in one command. This is the recommended option unless you plan to continue experimenting immediately.
Resources you used:
- CloudFormation stack (two-tier-app) containing: VPC, 4 subnets, Internet Gateway, route tables, 3 security groups, Auto Scaling Group with 2 EC2 instances, Launch Template, 1 RDS MySQL instance, 1 Application Load Balancer, 1 target group, 1 listener.
- EC2 key pair (two-tier-key) stored in AWS.
- Local project files (two-tier-stack.yaml and two-tier-key.pem).
Keep everything running
No action needed. Choose this if you are still actively experimenting with your stack or plan to extend it further.
- Monitor your Free Tier usage in the AWS Billing console under Free Tier to avoid surprise charges.
- With two t3.micro EC2 instances, one db.t3.micro RDS instance, and one ALB running continuously, you have approximately 15 days before exceeding Free Tier limits.
- Your local two-tier-key.pem file remains on your machine for SSH access.
Pause - I'll come back to this later
CloudFormation stacks cannot be paused. The RDS instance and EC2 instances continue running (and consuming hours) as long as the stack exists. Your best option is to delete the stack and redeploy later using your saved template.
- Keep your two-tier-stack.yaml file. You can redeploy the entire stack with a single command whenever you are ready.
- Delete the stack to stop all charges by running the commands in the Delete tab.
- When you are ready to resume, redeploy with aws cloudformation create-stack using the same template file.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes every AWS resource the stack created.
- Delete the CloudFormation stack by running:
aws cloudformation delete-stack --stack-name two-tier-app
- Wait for deletion to complete by running:
aws cloudformation wait stack-delete-complete --stack-name two-tier-app
- This removes the VPC, all subnets, the Internet Gateway, route tables, security groups, the Auto Scaling Group, all EC2 instances, the RDS database, the ALB, target group, and listener.
- Delete the EC2 key pair from AWS by running:
aws ec2 delete-key-pair --key-name two-tier-key
- Delete the local project files by running:
rm two-tier-stack.yaml two-tier-key.pem
- Confirm the stack is gone by running:
aws cloudformation describe-stacks --stack-name two-tier-app
- You should see an error stating the stack does not exist, confirming all resources have been removed.
That's a Wrap!
That's a Wrap!
Nice work! You just deployed a production-style two-tier web application entirely through a single CloudFormation YAML template.
You've learned how to:
- Built a two-tier architecture with public/private subnet isolation, deploying and tearing down the entire stack with a single command.
- Configured an Application Load Balancer to distribute HTTP traffic across multiple EC2 instances and watched hostnames rotate on refresh.
- Diagnosed and fixed a security group misconfiguration causing 502 errors by tracing the trust chain from ALB to EC2.
- Secret Mission: Replaced fixed EC2 instances with a self-healing Auto Scaling Group that automatically replaces failed instances.
Ready to quiz yourself?