Deploy a 3-Tier Web App on AWS EC2

Build a highly available Flask app on EC2 backed by a private RDS database.

Introduction

30 Second Summary

Every website you use, from your bank to your favourite streaming service, splits its work into layers: something catches your request, something processes it, and something stores the data. This layered setup keeps each piece independent so teams can scale, secure, and update them separately.

In this project, you will deploy a complete 3-tier web application on AWS. You will wire together a load balancer, two application servers, and a private database to serve a live page that anyone on the internet can visit.

What You'll Build

When you share your finished URL with a friend, their browser hits a load balancer that routes to one of two servers, which pulls live data from a database hidden in a private network zone.

By the end of this project, you'll have:

  • A public URL (Application Load Balancer DNS name) you can share with anyone that displays live data pulled from a real database, proving all three tiers are connected.
  • Two EC2 instances in different Availability Zones serving the same app. If one goes down, the other keeps your site online.
  • An RDS MySQL database in a private subnet that only your app servers can reach. No one on the internet can connect to it directly.
  • Security groups enforcing least-privilege access between each tier: internet to load balancer only, load balancer to app servers only, app servers to database only.
  • Secret Mission: Add a health check endpoint and watch your load balancer automatically route around a failed instance.

Are there any prerequisites?

You need an AWS account (created after July 2025 to get free credits that cover this project). No other tools are required beyond a terminal for SSH, which your computer already has.

Before We Start

Before diving into AWS, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal before any hands-on work begins.

Set Up Your AWS Environment

Before building any infrastructure, you need two things in place. A region where all your resources will live, and a key pair that lets you SSH into EC2 instances later.

Picking a region upfront ensures every resource you create lands in the same place. The key pair is your secure pass for connecting to servers over the command line.

In this step, get ready to:
  • Select your AWS region.
  • Create an EC2 key pair for SSH access.
Sign in to AWS and navigate to Key Pairs

All resources in this project will live in a single AWS region. Choosing one now prevents accidental cross-region issues later.

  • Open your browser and go to the AWS Management Console at https://console.aws.amazon.com.
  • Sign in with your AWS account credentials.
  • In the top-right corner of the console, click the region dropdown.
  • Select US East (N. Virginia) us-east-1 as your region.
  • In the top search bar, type EC2 and click EC2 from the results to open the EC2 Dashboard.
  • In the left navigation pane, under Network & Security, click Key Pairs.

You should see the Key Pairs page with an empty list (or any existing key pairs if you have created some before).

What is a key pair?

A key pair is a set of cryptographic keys (one public, one private) that lets you prove your identity when connecting to a remote server. AWS keeps the public key on the server. You keep the private .pem file on your local machine.

When you SSH into an EC2 instance, the server checks whether your private key matches the stored public key. If it does, you are in.

Create your key pair and set file permissions
  • Click the Create key pair button.
  • For Name, enter three-tier-key.
  • For Key pair type, select RSA.
  • For Private key file format, select pem.
  • Click Create key pair.

Your browser automatically downloads a file called three-tier-key.pem. This is your private key. AWS will never let you download it again.

Why can I only download this once?

AWS stores only the public half of the key pair. The private half exists only in the file you just downloaded. If you lose it, you must create a new key pair and re-associate it with any instance that needs it.

Next, you need to lock down the file permissions so that only you can read it. SSH refuses to use a key file that other users could access.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Terminal.
  • Navigate to your Downloads folder by running this command:
cd ~/Downloads
  • Set the file permissions so only you can read the key by running this command:
chmod 400 three-tier-key.pem
  • Verify the permissions are correct by running:
ls -l three-tier-key.pem

You should see -r-------- at the start of the output. This confirms only the file owner has read access.

What does chmod 400 do?

The chmod command changes file permissions on Unix-based systems. The number 400 means read-only for the file owner, and no access at all for anyone else.

SSH enforces this as a safety measure. If the key file is readable by other users on the machine, SSH refuses to use it and shows an "Unprotected private key file" error.

Seeing 'No such file or directory'?

Make sure you are in the folder where your browser downloaded the file. Most browsers save to ~/Downloads by default. If your browser saves elsewhere, cd to that folder instead.

Also check that the filename matches exactly. If you named your key pair something other than three-tier-key, use that name with .pem appended.

Help me find my downloaded .pem file.

Confirm your key pair is ready

Before you move on, what do you expect to see if you switch back to the AWS Console and look at the Key Pairs page?

  • Switch back to the AWS Console in your browser.
  • On the Key Pairs page, confirm that three-tier-key appears in the list with type rsa.

You should see your key pair listed with its name, type, and the date it was created. This confirms AWS has the public half stored and your local machine has the private half with correct permissions.

Your AWS environment is ready. Next up, you will build a custom VPC with public and private subnets to give your application proper network segmentation.

Build the VPC Network and Security Groups

Your key pair is ready. Now you need a network for your three-tier app to live in.

The default VPC that AWS gives every account has only public subnets. That means any database you launch there is reachable from the internet. A custom VPC with both public and private subnets gives you network segmentation where the database lives in an isolated zone that the internet cannot touch.

In this step, get ready to:
  • Create a custom VPC with public and private subnets across two Availability Zones.
  • Create three security groups that enforce tier-to-tier communication rules.
  • Verify that traffic can only flow in one direction: internet to ALB to EC2 to RDS.
Create the VPC using the wizard

AWS provides a wizard that creates your VPC, subnets, route tables, and gateways in a single operation. This saves you from wiring each piece individually.

  • In the AWS Console, type VPC in the top search bar and select VPC.
  • In the left navigation pane, click Your VPCs.
  • Click Create VPC.
  • On the Create VPC page, select VPC and more.

Why VPC and more?

This option creates subnets, route tables, an Internet Gateway, and a NAT Gateway together. Choosing "VPC only" would force you to create each of those separately.

  • Under Name tag auto-generation, make sure Auto-generate is selected and enter three-tier as the name prefix.
  • For IPv4 CIDR block, enter 10.0.0.0/16.
  • Set Number of Availability Zones to 2.
  • Set Number of public subnets to 2.
  • Set Number of private subnets to 2.
  • Set NAT gateways to In 1 AZ.
  • Set VPC endpoints to None.
  • Click Create VPC.

AWS takes about 30 seconds to provision all the resources. You will see a green success banner and a resource map showing your VPC, subnets, route tables, and gateways.

Why a NAT Gateway?

Your RDS database will live in a private subnet with no direct internet access. The NAT Gateway gives private-subnet resources a path to reach the internet for essential tasks like engine patching, without exposing them to inbound traffic.

  • Click View VPC to see your new VPC details.
  • In the left navigation pane, click Subnets.
  • Filter by your VPC (search for three-tier) and confirm you see 4 subnets: 2 public and 2 private, each in a different Availability Zone.
Create the security groups

A security group acts as a virtual firewall for your resources. You will create three security groups that form a chain: internet traffic can reach the ALB, the ALB can reach EC2, and EC2 can reach RDS. Nothing else is allowed.

You need to create them in order because each one references the previous one as its traffic source.

  • In the VPC Console left navigation pane, click Security groups.
  • Click Create security group.
  • For Security group name, enter ALB-SG.
  • For Description, enter Allow HTTP from internet.
  • For VPC, remove the default VPC and select your three-tier VPC from the dropdown.
  • Under Inbound rules, click Add rule.
  • Set Type to HTTP. This auto-fills port 80 and protocol TCP.
  • Set Source to Anywhere-IPv4 (0.0.0.0/0).
  • Click Create security group.

You should see a confirmation page showing ALB-SG is created. Now create the second security group.

  • Click Security groups in the left pane to return to the list, then click Create security group.
  • For Security group name, enter EC2-SG.
  • For Description, enter Allow HTTP from ALB and SSH from my IP.
  • For VPC, remove the default and select your three-tier VPC.
  • Under Inbound rules, click Add rule.
  • Set Type to HTTP (port 80).
  • Set Source to Custom, then in the search field type ALB-SG and select the security group that appears.
  • Click Add rule again to add a second rule.
  • Set Type to SSH (port 22).
  • Set Source to My IP. The Console auto-fills your current public IP address.
  • Click Create security group.

Why My IP for SSH?

Setting the SSH source to My IP restricts remote access to only your current machine. Opening SSH to 0.0.0.0/0 would let anyone on the internet attempt to connect to your instances.

EC2-SG is created. Now create the final security group for the database tier.

  • Click Security groups in the left pane to return to the list, then click Create security group.
  • For Security group name, enter RDS-SG.
  • For Description, enter Allow MySQL from EC2 only.
  • For VPC, remove the default and select your three-tier VPC.
  • Under Inbound rules, click Add rule.
  • Set Type to MYSQL/Aurora. This auto-fills port 3306.
  • Set Source to Custom, then type EC2-SG in the search field and select the security group.
  • Click Create security group.

Why reference a security group instead of an IP?

EC2 instances get new private IPs when replaced. By referencing the EC2-SG security group as the source, any current or future instance using that group automatically gains database access. You never need to update an IP list.

Verify the security group chain

Before you move on, trace the traffic path through your security groups to confirm least-privilege access is enforced.

  • In the Security groups list, click on ALB-SG and select the Inbound rules tab.
  • Confirm it shows: Type HTTP, Port range 80, Source 0.0.0.0/0.
  • Go back and click on EC2-SG. Check its Inbound rules tab.
  • Confirm it shows two rules: HTTP port 80 from ALB-SG, and SSH port 22 from your IP.
  • Go back and click on RDS-SG. Check its Inbound rules tab.
  • Confirm it shows: Type MYSQL/Aurora, Port range 3306, Source EC2-SG.

Before you move on, think about this: could someone on the internet connect directly to your EC2 instance on port 80 without going through the ALB? Could they connect to RDS on port 3306?

The answer to both is no. EC2-SG only accepts HTTP from ALB-SG, not from 0.0.0.0/0. RDS-SG only accepts MySQL from EC2-SG. The chain is enforced.

Don't see your three-tier VPC in the security group VPC dropdown?

Make sure you are in the same region where you created the VPC. Check the region selector in the top-right corner of the Console.

If the VPC still does not appear, go back to Your VPCs in the left pane and confirm the three-tier VPC shows a State of Available.

help me troubleshoot why my VPC is not showing up when creating a security group

Your network is fully built. You have a VPC with isolated public and private subnets, and security groups that only allow traffic to flow in one direction through the tiers. Next up, you will launch an EC2 instance and deploy a Flask app to prove the network works.

Launch EC2 and Deploy Flask

Your VPC and security groups are locked in. The network is segmented, and the rules are set. But there's nothing actually running inside it yet.

In this step, you'll launch an EC2 instance into one of your public subnets and deploy a Flask web app on it. Seeing a live page in your browser proves your network, subnets, and security groups all work together correctly.

In this step, get ready to:
  • Launch a t3.micro EC2 instance in a public subnet.
  • SSH into the instance and deploy a Flask app on port 80.
  • Verify the app loads in your browser via the public IP.
Launch an EC2 instance in the public subnet

Your app tier needs a server to run on. You'll launch an EC2 instance into one of the public subnets you created, attach the EC2-SG security group, and assign a public IP so you can reach it from your browser.

  • In the AWS Console, navigate to the EC2 service using the search bar at the top.
  • Click Launch instance.
  • Under Name and tags, enter three-tier-app-1.
  • Under Application and OS Images, select Amazon Linux 2023 AMI (it should be the default selection at the top).
  • Under Instance type, select t3.micro.
  • Under Key pair, select the key pair you created in Step 1.
  • Under Network settings, click Edit.
  • Set VPC to your three-tier VPC.
  • Set Subnet to one of the public subnets (any one with "public" in its name).
  • Set Auto-assign public IP to Enable.
  • Under Firewall (security groups), select Select existing security group.
  • Choose EC2-SG from the dropdown.
  • Leave everything else as default and click Launch instance.

Wait for the instance state to change to Running in the Instances list. This takes about 30 seconds.

Why t3.micro?

For AWS accounts created after July 2025, t3.micro instances are covered by free tier credits. You get 6 months of usage from account creation. This keeps your project cost at $0.

  • Click on your three-tier-app-1 instance in the list.
  • In the Details tab below, find the Public IPv4 address field and copy that IP address. You'll need it for the next substep.

Instance stuck in Pending?

Give it up to 2 minutes. If it stays pending, check that you selected a public subnet (not a private one) and that Auto-assign public IP is enabled.

Help me troubleshoot this

SSH into the instance and install Flask

With your instance running, you'll connect to it via SSH and install the Python packages your app needs. The EC2-SG security group already allows SSH from your IP address.

  • Open a terminal on your local machine.
  • Connect to your instance by running this command:
ssh -i [[KEYPATH="path/to/your-key.pem"]] ec2-user@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]

What does this command do?

-i tells SSH which private key file to use for authentication. ec2-user is the default username on Amazon Linux 2023 instances. The IP address is the public IP you copied from the EC2 Console.

If prompted "Are you sure you want to continue connecting?", type yes and press Enter. You'll see the terminal prompt change to something like [ec2-user@ip-10-0-x-x ~]$ confirming you're inside the instance.

Permission denied or connection refused?

  • If you see "Permission denied", make sure your .pem file has the correct permissions. Run chmod 400 on it again.
  • If the connection times out, confirm the EC2-SG inbound rule allows SSH (port 22) from your current IP. Your IP may have changed since you set it up.

Help me fix my SSH connection

  • Install pip and the Python packages your app needs by running this command:
sudo dnf install python3-pip -y && sudo pip3 install flask==3.1.3 pymysql==1.2.0

What does this command do?

dnf install python3-pip -y installs the pip package manager. Then pip3 install fetches Flask (your web framework) and PyMySQL (the MySQL database connector you'll use in the next step).

Amazon Linux 2023 ships with Python 3.9 pre-installed, so you only need to add pip and the libraries.

You should see output ending with "Successfully installed" and the package names listed. This confirms both packages are ready.

Create and run the Flask app

Now you'll create a small Python file that serves a web page. This is the application tier of your 3-tier architecture. It will listen on port 80 so browsers can reach it directly.

  • Create app.py by running this command:
cat > app.py << 'EOF'
from flask import Flask
import pymysql

app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello from the App Tier! Flask is running on EC2."

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=80)
EOF

What does this code do?

  • app = Flask(__name__) creates the Flask web application.
  • @app.route("/") tells Flask to respond when someone visits the root URL.
  • host="0.0.0.0" makes the app listen on all network interfaces (not just localhost), so external traffic can reach it.
  • port=80 is the standard HTTP port that browsers use by default.
  • import pymysql is included now so it's ready for the database connection in the next step.
  • Start the Flask app by running:
sudo python3 app.py

Why sudo?

Port 80 is a privileged port on Linux. Only root can bind to ports below 1024. Running with sudo gives Python permission to listen on port 80.

Your terminal will show output including * Running on http://0.0.0.0:80 and the cursor will stop blinking. Flask is now running and occupying this terminal session.

Seeing "Address already in use"?

Another process is using port 80. Run sudo kill $(sudo lsof -t -i:80) to free the port, then try sudo python3 app.py again.

Help me free up port 80

Before you check the browser, what do you expect to see when you visit the EC2 public IP? Think about what your hello() function returns.

  • Open a new browser tab and navigate to http:// followed by your EC2 instance's public IPv4 address (the one you copied from the Console earlier).

You should see: Hello from the App Tier! Flask is running on EC2.

What does this prove?

This single page load proves three things at once. Your EC2 instance launched correctly in the public subnet. The EC2-SG security group allows inbound HTTP traffic on port 80. Your Flask app is serving responses to the internet.

This direct public IP access is temporary for testing. In Step 5, production traffic will flow through the ALB instead.

Page not loading or timing out?

  • Make sure you're using http:// and not https:// in the URL. Your app does not have an SSL certificate.
  • Confirm your EC2-SG allows inbound traffic on port 80. Check whether the source security group rule is letting your request through.
  • Verify Flask is actually running in your SSH terminal. You should see the * Running on message.

Help me debug why my page won't load

Your app tier is live. You have a running server proving the network works end to end. Next up, you'll add a real database behind this app and connect the two tiers together.

Add RDS in the Private Subnet

Your Flask app currently returns a hardcoded string. That proves the app tier is working, but it does not prove the three tiers can actually talk to each other.

In this step, you will create an RDS MySQL database in the private subnets and update your Flask app to query it. The moment your page displays a live database name instead of a static message, you will know all three tiers are wired together.

In this step, get ready to:
  • Create a DB subnet group and launch an RDS MySQL instance in the private subnets.
  • Update app.py to connect to the database and display a live query result.
  • Verify the three-tier chain by seeing live database output in the browser.
Create the DB subnet group

RDS needs to know which subnets it can place your database in. A DB subnet group is a collection of subnets across at least two Availability Zones that RDS uses for placement and recovery.

  • In the AWS Console, type RDS in the search bar and select the RDS service.
  • In the left navigation pane, click Subnet groups.
  • Click Create DB Subnet Group.
  • For Name, enter three-tier-db-subnets.
  • For Description, enter Private subnets for RDS.
  • For VPC, select your three-tier VPC.
  • Under Add subnets, select both Availability Zones that your VPC uses.
  • For each Availability Zone, select the private subnet (the ones with "private" in the name). Do not select public subnets.
  • Click Create.

You should see your new subnet group listed on the Subnet groups page.

What is a DB subnet group?

A DB subnet group tells RDS which subnets inside your VPC it can use. RDS requires subnets in at least two Availability Zones so it has the option to recover your database in a different AZ if needed.

Launch the RDS MySQL instance

Now you will create the actual database that your Flask app will connect to.

  • In the RDS left navigation pane, click Databases.
  • Click Create database.
  • For Choose a database creation method, select Standard create.
  • Under Engine options, select MySQL. For the version, choose MySQL 8.0.
  • Under Templates, select Free tier (if available) or Dev/Test.
  • Under Settings, set the DB instance identifier to three-tier-db.
  • For Master username, enter admin.
  • For Credentials management, select Self managed. Enter a password you will remember and confirm it.

Remember your password

Write down the master password somewhere safe. You will need it when configuring your Flask app to connect to the database.

  • Under Instance configuration, select Burstable classes and choose db.t3.micro.
  • Under Connectivity, for Virtual Private Cloud (VPC), select your three-tier VPC.
  • For DB subnet group, select three-tier-db-subnets (the one you just created).
  • For Public access, select No.
  • For VPC security group, select Choose existing. Remove the default security group and select RDS-SG.
  • Expand Additional configuration at the bottom. Under Initial database name, enter myappdb.
  • Scroll to the bottom and click Create database.

RDS will start provisioning your database. The status shows Creating and takes approximately 5 to 10 minutes to become Available.

Why does this take so long?

RDS is provisioning an actual MySQL server, configuring storage, setting up automated backups, and attaching the instance to your private subnets. This is a real production-grade database, not a local SQLite file.

  • Wait until the status changes to Available. You can refresh the page periodically to check.
  • Once available, click on your database instance name (three-tier-db).
  • In the Connectivity & security section, find the Endpoint value. It looks something like three-tier-db.abc123xyz.us-east-1.rds.amazonaws.com. Copy this endpoint.

Don't see your database?

Make sure you are in the same AWS Region where you created all your other resources. Check the region selector in the top-right corner of the console.

If the status still shows Creating after 10 minutes, keep waiting. RDS can occasionally take up to 15 minutes. Help me troubleshoot my RDS instance creation.

Update app.py to connect to RDS

Your app currently returns a static string. Now you will replace the hello function with code that connects to RDS, runs a query, and displays the result.

  • Switch back to your SSH terminal connected to three-tier-app-1.
  • Stop the running Flask app by pressing Ctrl+C.
  • Open app.py for editing by running:
sudo nano app.py
  • Delete all the existing content and replace it with the following code:
from flask import Flask
import pymysql

app = Flask(__name__)

@app.route("/")
def hello():
    try:
        # Connect to the RDS MySQL database
        connection = pymysql.connect(
            host="your-rds-endpoint-here",
            user="admin",
            password="your-password-here",
            database="myappdb",
            cursorclass=pymysql.cursors.DictCursor
        )
        # Query the database name to prove connectivity
        with connection:
            with connection.cursor() as cursor:
                cursor.execute("SELECT DATABASE()")
                result = cursor.fetchone()
                db_name = result["DATABASE()"]
        return f"Hello, connected to database: {db_name}"
    except Exception as e:
        return f"Error connecting to database: {e}"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=80)

What does this code do?

  • `pymysql.connect(...)` opens a TCP connection from your EC2 instance to the RDS endpoint on port 3306.
  • `SELECT DATABASE()` is a MySQL query that returns the name of the currently connected database.
  • The `try/except` block catches connection errors and displays them on the page instead of crashing the app. This helps you diagnose any connectivity issues.
  • Replace your-rds-endpoint-here with the RDS endpoint you copied from the console (e.g. three-tier-db.abc123xyz.us-east-1.rds.amazonaws.com).
  • Replace your-password-here with the master password you set when creating the RDS instance.
  • Save the file with Ctrl+O, press Enter, then exit nano with Ctrl+X.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app.py with your actual RDS endpoint and password filled in.

ⓧ I'd like to double check the full code

Your app.py should look exactly like this (with your own endpoint and password substituted):

from flask import Flask
import pymysql

app = Flask(__name__)

@app.route("/")
def hello():
    try:
        connection = pymysql.connect(
            host="your-rds-endpoint-here",
            user="admin",
            password="your-password-here",
            database="myappdb",
            cursorclass=pymysql.cursors.DictCursor
        )
        with connection:
            with connection.cursor() as cursor:
                cursor.execute("SELECT DATABASE()")
                result = cursor.fetchone()
                db_name = result["DATABASE()"]
        return f"Hello, connected to database: {db_name}"
    except Exception as e:
        return f"Error connecting to database: {e}"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=80)
Test the database connection
  • Restart the Flask app by running:
sudo python3 app.py

Before you open your browser, think about this: do you expect the connection to succeed, or will it time out? What two things must be true for EC2 to reach RDS?

  • Open your browser and navigate to http:// followed by your EC2 instance's public IP.

✔️ I see "Hello, connected to database: myappdb"

The three-tier chain is working. Your request went from the browser to EC2, EC2 connected to RDS in the private subnet, and the live database name came back. The security group chain you built in Step 2 (EC2-SG to RDS-SG on port 3306) is proven.

ⓧ I see a timeout or connection error

A timeout means network traffic from EC2 is not reaching RDS. Two things must both be true for connectivity to work: the security group chain AND network routing.

  • Go to the EC2 Console, find RDS-SG, and check its inbound rules. The source for port 3306 must reference the EC2-SG security group ID (not an IP address). If it references an IP, edit the rule to use the EC2-SG security group instead.
  • Go to the VPC Console, select the route table attached to your private subnets. Confirm there is a route to 0.0.0.0/0 pointing at the NAT gateway. Without this route, RDS cannot reach the internet for patches, but more importantly, routing between subnets within the same VPC uses the local route and should work regardless.
  • Confirm the RDS instance is in your three-tier VPC (same VPC as your EC2 instance). If it ended up in the default VPC, it is on a completely different network.
  • Verify the RDS status is Available (not still Creating). A database that is not yet available will not accept connections.
  • Double-check you pasted the correct endpoint in app.py with no trailing spaces or missing characters.

After fixing the issue, save app.py, stop the app with Ctrl+C, restart with sudo python3 app.py, and refresh the browser.

Help me debug why my EC2 instance cannot connect to RDS.

Why both security groups AND routing must be correct

Security groups control whether traffic is allowed. Route tables control where traffic is sent. Even with a perfect security group rule, if the route table in the private subnet does not know how to reach the EC2 subnet (or vice versa), the packets never arrive.

Inside a VPC, the local route (10.0.0.0/16 targeting local) handles this automatically. The most common cause of a timeout is the RDS instance ending up in the wrong VPC entirely.

Your three tiers are now wired together. The browser hits EC2, EC2 queries RDS in the private subnet, and the live database name comes back. Next up, you will add a load balancer and a second EC2 instance to make this setup highly available.

Front with an ALB and Scale Across Availability Zones

Your app is live and database-connected, but it hangs by a thread. Right now everything flows through one EC2 instance at one public IP address. If that instance goes down, your entire application disappears.

An Application Load Balancer solves this by giving you a stable DNS endpoint that distributes traffic across multiple instances. Adding a second instance in a different Availability Zone is what "highly available" literally means on a job description.

In this step, get ready to:
  • Launch a second EC2 instance in a different Availability Zone.
  • Create an Application Load Balancer with a target group.
  • Verify the ALB DNS name serves your database-connected app.
Launch a second EC2 instance in a different Availability Zone

Your first instance sits in one Availability Zone. To survive the failure of an entire data centre, you need a second instance in a different AZ running the same app.

  • In the EC2 Console, click Launch instance.
  • Under Name and tags, enter three-tier-app-2.
  • Under Application and OS Images, select Amazon Linux 2023 (the same AMI you used for the first instance).
  • Under Instance type, select t3.micro.
  • Under Key pair, select the same key pair you used for the first instance.
  • Under Network settings, click Edit.
  • For VPC, select your three-tier VPC.
  • For Subnet, select the second public subnet (it will be in a different AZ from your first instance).

Pick the other AZ

Your first instance is in one public subnet. Choose the other public subnet here. The VPC wizard created two public subnets in different AZs, so selecting the one you did not use before guarantees a different Availability Zone.

  • For Auto-assign public IP, select Enable.
  • For Firewall (security groups), select Select existing security group and choose EC2-SG.
  • Click Launch instance.
  • Wait for the instance state to change to Running and note its public IPv4 address.

Now SSH into the new instance and deploy the same Flask app. Connect by running this command:

ssh -i [[PEMPATH="path to your .pem file"]] ec2-user@[[EC2_2_PUBLIC_IP="public IPv4 address of three-tier-app-2"]]
  • Install Flask and PyMySQL by running this command:
sudo dnf install python3-pip -y && sudo pip3 install flask==3.1.3 pymysql==1.2.0

What does this install?

This installs the same two packages your first instance uses. flask==3.1.3 is the web framework that serves HTTP requests. pymysql==1.2.0 is a pure-Python MySQL client that connects to your RDS database.

  • Create app.py by running this command:
sudo nano app.py
  • Paste the following code into the editor. Replace your-rds-endpoint-here with your RDS endpoint and your-password-here with the master password you set in Step 4.
from flask import Flask
import pymysql

app = Flask(__name__)

@app.route("/")
def hello():
    try:
        connection = pymysql.connect(
            host="your-rds-endpoint-here",
            user="admin",
            password="your-password-here",
            database="myappdb",
            cursorclass=pymysql.cursors.DictCursor
        )
        with connection:
            with connection.cursor() as cursor:
                cursor.execute("SELECT DATABASE()")
                result = cursor.fetchone()
                db_name = result["DATABASE()"]
        return f"Hello, connected to database: {db_name}"
    except Exception as e:
        return f"Error connecting to database: {e}"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=80)

What does this code do?

This is the same Flask app running on your first instance. It connects to the shared RDS database in the private subnet and returns the live database name. Both instances point at the same RDS endpoint, so both serve identical responses.

  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
  • Start the Flask app by running:
sudo python3 app.py
  • Open a new browser tab and navigate to http:// followed by the public IPv4 address of three-tier-app-2.

You should see "Hello, connected to database: myappdb" confirming the second instance is live and database-connected.

Seeing a connection timeout?

Check that you selected the correct security group (EC2-SG) when launching the instance. EC2-SG allows inbound HTTP on port 80 from ALB-SG and SSH from your IP.

If the database connection fails, verify you pasted the correct RDS endpoint and password in app.py. The endpoint must match what you used on instance 1.

Why can't my second EC2 instance connect to RDS?

Create an Application Load Balancer and target group

With two instances running the same app in different AZs, you need a single stable endpoint that routes traffic to both. That is what the ALB provides.

  • In the EC2 Console left sidebar, scroll down to Load Balancing and click Target Groups.
  • Click Create target group.
  • For Choose a target type, select Instances.
  • For Target group name, enter three-tier-tg.
  • For Protocol, keep HTTP on port 80.
  • For VPC, select your three-tier VPC.
  • Under Health checks, keep the health check path as /.
  • Click Next.
  • On the Register targets page, select both three-tier-app-1 and three-tier-app-2.
  • Click Include as pending below.
  • Click Create target group.

What is a target group?

A target group is a collection of instances (or IPs) that the load balancer sends traffic to. The health check path / means the ALB will periodically hit your app's root route and mark the instance healthy only if it gets a 200 response.

  • In the left sidebar under Load Balancing, click Load Balancers.
  • Click Create load balancer.
  • Under Application Load Balancer, click Create.
  • For Load balancer name, enter three-tier-alb.
  • For Scheme, select Internet-facing.
  • For IP address type, keep IPv4.
  • Under Network mapping, for VPC, select your three-tier VPC.
  • Select both Availability Zones and their corresponding public subnets.
  • Under Security groups, remove the default security group and select ALB-SG.
  • Under Listeners and routing, keep the listener as HTTP on port 80.
  • For Default action, select the three-tier-tg target group you just created.
  • Click Create load balancer.

What did you just create?

The ALB sits in both public subnets and listens on port 80. When a request arrives, it forwards it to one of the healthy instances in your target group. ALB-SG only allows inbound HTTP from the internet, and EC2-SG only allows HTTP from ALB-SG. This means end users can no longer bypass the load balancer to hit EC2 directly (unless they know the instance IP).

Verify the ALB serves your app across both instances

The ALB takes a minute or two to provision and run health checks against your instances.

  • In the Load Balancers list, click your three-tier-alb to open its details.
  • Wait until the State column shows Active.
  • Click Target Groups in the sidebar, then click three-tier-tg.
  • Under the Targets tab, confirm both instances show a Health status of healthy.

Targets showing unhealthy?

The most common cause is that Flask is not running on one of the instances. SSH into the unhealthy instance and confirm sudo python3 app.py is active. If the process stopped, restart it.

Also verify EC2-SG allows inbound HTTP on port 80 from ALB-SG (not from 0.0.0.0/0). The health check traffic comes from the ALB, so EC2-SG must reference ALB-SG as the source.

Help me fix my unhealthy ALB targets.

Before you open the ALB URL, think about what you expect to see. The ALB forwards your request to one of the two instances. Both instances connect to the same RDS database. What should the response say?

  • Copy the DNS name from the ALB details page (it looks like three-tier-alb-123456789.us-east-1.elb.amazonaws.com).
  • Paste it into your browser address bar prefixed with http:// and press Enter.

You should see "Hello, connected to database: myappdb" served through the ALB. Refresh multiple times. The response comes back consistently because both instances serve the same content from the shared database.

What about the EC2 public IPs?

The public IP addresses of your EC2 instances are now unnecessary for end users. All production traffic flows through the ALB DNS name. The ALB provides a stable endpoint that survives individual instance failures and distributes load.

You now have a fully working 3-tier architecture. Traffic flows from the internet through your ALB, to EC2 instances in two Availability Zones, down to a private RDS database. This is production-grade cloud infrastructure. Next up, push it further with a Secret Mission that proves the ALB actually routes around failures.

Secret mission

Add a Health Check and Demonstrate ALB Failover

High availability is not just having two instances. It means the system automatically routes around failures. In this secret mission, you will add a dedicated health check endpoint to your Flask app, update the ALB to use it, then simulate a failure to prove the load balancer reroutes traffic without any downtime.

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. Your free credits cover all running resources, but the ALB and NAT Gateway are not part of the standard free tier and will consume credits while active.

Cost warning

The Application Load Balancer costs approximately $0.02/hour (~$16/month) and the NAT Gateway costs approximately $0.045/hour (~$32/month). Together, leaving them running will consume roughly $48/month from your free credits. If you are done with this project, delete these resources to preserve your remaining credits.

Resources you used:

  • 2x EC2 t3.micro instances (three-tier-app-1, three-tier-app-2).
  • 1x RDS db.t3.micro MySQL instance (myappdb).
  • 1x Application Load Balancer and target group.
  • 1x NAT Gateway with associated Elastic IP.
  • VPC (three-tier) with subnets, Internet Gateway, route tables, and security groups.
  • 1x RDS DB subnet group.
  • 1x EC2 key pair and local .pem file.

Keep everything running

No action needed. Choose this if you are still actively building on this architecture or plan to use it again within the next few days.

  • Monitor your remaining credits in the AWS Billing console.
  • The ALB and NAT Gateway will continue consuming credits while running.
  • EC2 and RDS instances are covered by free tier for 6 and 12 months respectively.

Pause - I'll come back to this later

Shut down compute resources to save credits while keeping your configuration intact. Best if you plan to resume within a few days.

  • In the EC2 Console, select both instances (three-tier-app-1 and three-tier-app-2), choose Instance state, then Stop instance.
  • In the RDS Console, select your DB instance, choose Actions, then Stop temporarily. Acknowledge that it will restart automatically after 7 days.
  • The ALB and NAT Gateway will still incur charges (~$48/month combined) even while compute is stopped. If pausing for more than a few days, consider deleting the ALB and NAT Gateway and recreating them later.

Delete - I don't want to use this again

Remove all project resources permanently. Follow these steps in order to avoid dependency errors.

Step 1: Delete the ALB and target group.

  • Open the EC2 Console and choose Load Balancers in the navigation pane.
  • Select your ALB, choose Actions, then Delete load balancer.
  • Type confirm and choose Delete.
  • In the navigation pane, choose Target Groups.
  • Select your target group, choose Actions, then Delete. Confirm by choosing Delete.

Step 2: Terminate both EC2 instances.

  • In the EC2 Console, choose Instances in the navigation pane.
  • Select both three-tier-app-1 and three-tier-app-2.
  • Choose Instance state, then Terminate instance. Confirm the termination.

Step 3: Delete the RDS instance.

  • Open the RDS Console and choose Databases in the navigation pane.
  • Select your DB instance, choose Actions, then Delete.
  • Deselect Create final snapshot? to skip the final snapshot.
  • Type delete me and choose Delete.
  • Wait for the instance to finish deleting before proceeding.

Step 4: Delete the DB subnet group.

  • In the RDS Console, choose Subnet groups in the navigation pane.
  • Select your DB subnet group, then choose Delete. Confirm the deletion.

Step 5: Delete the NAT Gateway and release the Elastic IP.

  • Open the VPC Console and choose NAT gateways in the navigation pane.
  • Select the NAT gateway, choose Actions, then Delete NAT gateway.
  • Type delete and choose Delete.
  • In the navigation pane, choose Elastic IPs.
  • Select the Elastic IP that was associated with the NAT Gateway, choose Actions, then Release Elastic IP addresses. Confirm by choosing Release.

Step 6: Delete the VPC.

  • In the VPC Console, choose Your VPCs in the navigation pane.
  • Select the three-tier VPC, choose Actions, then Delete VPC.
  • The console will automatically remove the associated subnets, route tables, Internet Gateway, security groups, and Network ACLs.
  • Type delete and choose Delete.

Step 7: Delete the key pair and local .pem file.

  • In the EC2 Console, choose Key Pairs under Network & Security in the navigation pane.
  • Select your key pair, choose Actions, then Delete. Confirm the deletion.
  • On your local machine, delete the .pem file you downloaded earlier.

Nice Work!

Nice Work!

You just deployed a production-style 3-tier web application on AWS. You have a live public URL serving data from a database that no one on the internet can reach directly.

You've learned how to:

  • Build a custom VPC with public and private subnets across two Availability Zones, isolating your database from the public internet.
  • Deploy a Flask application on EC2 that queries a MySQL database running on RDS, with security groups enforcing least-privilege access between every tier.
  • Create an Application Load Balancer that distributes traffic across two instances in different Availability Zones, giving you a stable public endpoint and high availability.
  • Secret Mission: Added a health check endpoint and proved the ALB automatically routes around failed instances.

Ready to quiz yourself?