Deploy a 3-Tier App on AWS
Build a production-grade VPC with isolated subnets, ALB, EC2, and RDS.
Introduction
30 Second Summary
Every time you open an app or visit a website, your request passes through layers of infrastructure that each have a specific job. This separation into layers is called a 3-tier architecture, and it is how most companies run their production workloads.
In this project, you will deploy a production-grade 3-tier web application on AWS with proper network isolation and security controls. You will build a custom VPC, deploy an app server and database in private subnets, and route internet traffic through an Application Load Balancer.
What You'll Build
A production-grade 3-tier architecture on AWS where internet traffic flows through an Application Load Balancer to an EC2 app server to an RDS MySQL database, with each tier isolated in its own subnet and secured by layered controls.
By the end of this project, you'll have:
- A fully functional 3-tier VPC with 6 subnets across 2 Availability Zones for high availability.
- A running web application accessible through an Application Load Balancer with traffic flowing through isolated tiers (ALB to EC2 to RDS).
- Production-grade security controls including security group chaining, NACLs, and zero public exposure for the database.
- Secret Mission: Add Auto Scaling to your app tier for resilience and horizontal scaling.
Are there any prerequisites?
You need an AWS account (paid plan) with the AWS CLI installed and configured. If you do not have the CLI set up yet, follow the official AWS documentation to install and run aws configure before starting this project.
Create the VPC and Subnet Foundation
Every production application on AWS lives inside a VPC (Virtual Private Cloud). A VPC is your own isolated network where you control who can talk to whom.
In this step, you will create the network foundation for your 3-tier architecture: a custom VPC, six subnets spread across two Availability Zones, and an Internet Gateway to connect your public subnets to the outside world.
In this step, get ready to:
- Create a custom VPC with a /16 CIDR block and enable DNS settings.
- Create six subnets across two Availability Zones (public, private app, private data).
- Create and attach an Internet Gateway to your VPC.
Create a custom VPC
A VPC defines the IP address space for all your resources. You will use the 10.0.0.0/16 CIDR block, which gives you 65,536 IP addresses to work with across all your subnets.
- Open the AWS Management Console and navigate to the VPC service by typing VPC in the top search bar.
- On the VPC dashboard, click Create VPC.
- For Resources to create, select VPC only.
- For Name tag, enter three-tier-vpc.
- For IPv4 CIDR block, select IPv4 CIDR manual input and enter 10.0.0.0/16.
- Leave Tenancy as Default.
- Click Create VPC.
Now enable DNS hostnames on your new VPC. DNS resolution is enabled by default, but DNS hostnames is not.
- In the navigation pane, click Your VPCs.
- Select the checkbox for three-tier-vpc.
- Click Actions, then Edit VPC settings.
- Check the box for Enable DNS hostnames.
- Click Save changes.
Why enable DNS hostnames?
DNS hostnames give your EC2 instances human-readable DNS names that map to their IP addresses. Without this, services like RDS cannot generate the endpoint URLs you need to connect to your database later.
Now verify your VPC was created using the AWS CLI. Run this command in your terminal:
aws ec2 describe-vpcs --filters "Name=tag:Name,Values=three-tier-vpc" --query "Vpcs[0].{VpcId:VpcId,CidrBlock:CidrBlock,State:State}"
What does this command do?
The --filters flag narrows results to your specific VPC by its Name tag. The --query flag formats the output to show only the VPC ID, CIDR block, and state.
You should see output showing your VPC ID, the CIDR block 10.0.0.0/16, and a state of available. Note down the VPC ID. You will use it throughout this project.
Not seeing your VPC?
Make sure your CLI is configured for the same region you used in the Console. Check your region with aws configure get region. If the regions don't match, add --region followed by your Console region to the command.
Help me troubleshoot why my VPC isn't showing in the CLI output.
Create six subnets across two Availability Zones
Your 3-tier architecture needs three types of subnets: public (for the load balancer), private app (for the EC2 server), and private data (for the database). You will create two of each, one in each Availability Zone, for high availability.
- In the VPC Console navigation pane, click Subnets.
- Click Create subnet.
- For VPC ID, select your three-tier-vpc.
You can add multiple subnets on the same page. Create all six subnets with these settings:
- Subnet 1: Subnet name = public-subnet-a, Availability Zone = first AZ (ending in a), IPv4 CIDR block = 10.0.1.0/24.
- Subnet 2: Subnet name = public-subnet-b, Availability Zone = second AZ (ending in b), IPv4 CIDR block = 10.0.2.0/24.
- Subnet 3: Subnet name = private-app-subnet-a, Availability Zone = first AZ (ending in a), IPv4 CIDR block = 10.0.10.0/24.
- Subnet 4: Subnet name = private-app-subnet-b, Availability Zone = second AZ (ending in b), IPv4 CIDR block = 10.0.11.0/24.
- Subnet 5: Subnet name = private-data-subnet-a, Availability Zone = first AZ (ending in a), IPv4 CIDR block = 10.0.20.0/24.
- Subnet 6: Subnet name = private-data-subnet-b, Availability Zone = second AZ (ending in b), IPv4 CIDR block = 10.0.21.0/24.
- Click Create subnet.
Why this CIDR layout?
The public subnets use 10.0.1.0/24 and 10.0.2.0/24. The app subnets use 10.0.10.0/24 and 10.0.11.0/24. The data subnets use 10.0.20.0/24 and 10.0.21.0/24. Grouping by tens makes the tier obvious from the IP alone.
Each /24 subnet provides 251 usable IP addresses (256 minus 5 reserved by AWS). This is more than enough for a production workload.
Now enable auto-assign public IP on both public subnets. This ensures instances in public subnets get a public IP address automatically.
- In the Subnets list, select the checkbox for public-subnet-a.
- Click Actions, then Edit subnet settings.
- Check the box for Enable auto-assign public IPv4 address.
- Click Save.
- Repeat the same steps for public-subnet-b.
Only public subnets get this setting
Do NOT enable auto-assign public IP on the private app or data subnets. The whole point of private subnets is that resources inside them are not directly reachable from the internet.
Seeing fewer than 6 subnets?
Make sure you selected three-tier-vpc when creating subnets, not the default VPC. If any subnet ended up in the wrong VPC, delete it and recreate it under the correct VPC.
Help me figure out why my subnets are not showing under my VPC.
Create and attach an Internet Gateway
An Internet Gateway is the door between your VPC and the public internet. Without it, nothing in your VPC can reach the outside world (or be reached from it).
- In the VPC Console navigation pane, click Internet gateways.
- Click Create internet gateway.
- For the name, enter three-tier-igw.
- Click Create internet gateway.
The gateway is created but not yet attached to your VPC. Attach it now.
- On the internet gateway details page, click Actions, then Attach to VPC.
- Select three-tier-vpc from the list.
- Click Attach internet gateway.
Now verify the attachment using the CLI by running this command:
aws ec2 describe-internet-gateways --filters "Name=tag:Name,Values=three-tier-igw" --query "InternetGateways[0].Attachments"
What should I see?
You should see output showing your VPC ID with a State of available. This confirms the Internet Gateway is successfully attached to your VPC.
Seeing an empty attachments list?
The Internet Gateway was created but not attached. Go back to the Internet gateways page in the Console, select your gateway, and use Actions > Attach to VPC to complete the attachment.
Help me attach my Internet Gateway to my VPC.
Your network foundation is in place. You have a VPC, six properly segmented subnets, and an Internet Gateway ready to route traffic. Next up, you will configure route tables and a NAT Gateway to control exactly how traffic flows between these tiers.
Configure Routing and NAT Gateway
Your subnets exist, but right now none of them know how to reach the internet or talk to each other beyond the local VPC range. Route tables are the traffic rules that tell each subnet where to send packets for different destinations.
In this step, you will create three route tables with different levels of access. Public subnets get full internet access through the Internet Gateway. Private app subnets get outbound-only internet access through a NAT Gateway. Private data subnets get no internet access at all.
In this step, get ready to:
- Create a public route table with a route to the Internet Gateway.
- Create a NAT Gateway and a private app route table for outbound internet access.
- Create an isolated data route table with zero internet connectivity.
Create the public route table
The public route table tells your public subnets how to reach the internet. It needs a route that sends all non-local traffic (0.0.0.0/0) to the Internet Gateway.
- In the VPC Console, select Route tables from the left navigation pane.
- Click Create route table.
- Enter public-rt as the Name.
- Select your VPC from the VPC dropdown.
- Click Create route table.
Now add the internet route to this table.
- Select the public-rt route table you just created.
- Click the Routes tab at the bottom of the page.
- Click Edit routes.
- Click Add route.
- Enter 0.0.0.0/0 in the Destination field.
- In the Target field, select Internet Gateway and choose your attached Internet Gateway.
- Click Save changes.
Next, associate this route table with both public subnets.
- Click the Subnet associations tab.
- Click Edit subnet associations.
- Select public-subnet-a and public-subnet-b.
- Click Save associations.
What does 0.0.0.0/0 mean?
The destination 0.0.0.0/0 is a catch-all rule meaning "any IP address not matched by a more specific route." Your route table already has a local route for 10.0.0.0/16 (traffic within the VPC). The 0.0.0.0/0 route handles everything else by sending it to the Internet Gateway.
Verify your public route table configuration using the CLI.
- Confirm your route table has the correct routes by running this command:
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=[[VPC_ID=\"your VPC ID\"]]" --query "RouteTables[?Tags[?Value=='public-rt']].Routes"
What should I see?
You should see two routes in the output: the local route with destination 10.0.0.0/16 and your new route with destination 0.0.0.0/0 pointing to your Internet Gateway ID (starting with igw-).
Not seeing the Internet Gateway route?
Make sure you selected the correct VPC when creating the route table. If you see an empty result, double-check the VPC ID in your filter matches the VPC you created in Step 1.
help me verify my public route table is configured correctly
Create the NAT Gateway and private app route table
Your private app subnets need outbound internet access so servers can download updates and reach external APIs. A NAT Gateway provides this access without exposing those servers to inbound internet traffic.
What is a NAT Gateway?
A NAT Gateway performs Network Address Translation. It lets instances in private subnets initiate outbound connections to the internet, but prevents the internet from initiating connections back in.
The NAT Gateway itself lives in a public subnet and requires an Elastic IP (a static public IP address). Private subnet traffic flows to the NAT Gateway, which translates the private source IP to its Elastic IP before forwarding the request to the internet.
First, allocate an Elastic IP address for the NAT Gateway.
- In the VPC Console, select Elastic IPs from the left navigation pane.
- Click Allocate Elastic IP address.
- Leave the defaults and click Allocate.
- Note the Allocation ID (it starts with eipalloc-).
Now create the NAT Gateway in your first public subnet.
- In the VPC Console, select NAT gateways from the left navigation pane.
- Click Create NAT gateway.
- Enter a name like nat-gw-public-a.
- Select public-subnet-a from the Subnet dropdown.
- For Connectivity type, leave Public selected.
- For Elastic IP allocation ID, select the Elastic IP you just allocated.
- Click Create NAT gateway.
Why put the NAT Gateway in a public subnet?
The NAT Gateway needs internet access itself so it can forward traffic from your private subnets. It must live in a public subnet that has a route to the Internet Gateway. Private subnet instances route their outbound traffic to the NAT Gateway, which then routes it to the internet on their behalf.
NAT Gateway takes a few minutes
The initial status of the NAT Gateway is Pending. Wait until the status changes to Available before proceeding. This typically takes 1-2 minutes.
While the NAT Gateway provisions, create the private app route table.
- Navigate back to Route tables in the left navigation pane.
- Click Create route table.
- Enter private-app-rt as the Name.
- Select your VPC from the VPC dropdown.
- Click Create route table.
Add the NAT Gateway route to this table.
- Select the private-app-rt route table.
- Click the Routes tab.
- Click Edit routes.
- Click Add route.
- Enter 0.0.0.0/0 in the Destination field.
- In the Target field, select NAT Gateway and choose the NAT Gateway you just created.
- Click Save changes.
Associate this route table with both private app subnets.
- Click the Subnet associations tab.
- Click Edit subnet associations.
- Select private-app-subnet-a and private-app-subnet-b.
- Click Save associations.
NAT Gateway not showing as a target option?
If you do not see your NAT Gateway in the target dropdown, it may still be in Pending status. Go back to NAT gateways in the left nav and confirm the status shows Available before adding the route.
help me troubleshoot my NAT Gateway not appearing as a route target
Create the data route table with no internet access
Your database tier should have zero outbound internet access. This means its route table only has the default local route for VPC-internal traffic.
- In Route tables, click Create route table.
- Enter data-rt as the Name.
- Select your VPC from the VPC dropdown.
- Click Create route table.
Why no routes for data subnets?
Every route table automatically includes a local route for 10.0.0.0/16 so resources within the VPC can communicate. By not adding a 0.0.0.0/0 route, you ensure the database tier cannot reach the internet at all. This is a key part of defense-in-depth for your data layer.
Associate this route table with both data subnets.
- Select the data-rt route table.
- Click the Subnet associations tab.
- Click Edit subnet associations.
- Select private-data-subnet-a and private-data-subnet-b.
- Click Save associations.
Verify the data route table has only the local route by checking the Routes tab. You should see only one route: destination 10.0.0.0/16 with target local.
Seeing an extra route in the data table?
If you accidentally added a 0.0.0.0/0 route, select the route in the Routes tab, click Edit routes, and click Remove on the unwanted route. The local route cannot be removed.
help me fix my data subnet route table
Your routing is fully configured. Public subnets can reach the internet directly, app subnets can reach it through the NAT Gateway, and data subnets are completely isolated. Next up, you will lock down traffic between tiers with security groups.
Set Up Security Groups with Chaining
Your routing is configured. Traffic knows how to reach the internet, how to flow through the NAT Gateway, and how to stay local within the VPC.
But right now, nothing is stopping resources from talking to each other freely. In this step, you'll create security groups that enforce strict tier-to-tier communication rules. You'll also add a NACL as a second defense layer on your data subnets.
In this step, get ready to:
- Create ALB and App security groups using security group chaining.
- Create a Database security group that only accepts traffic from the App tier.
- Add a custom NACL to the data subnets as a second defense layer.
Create the ALB and App security groups
Security group chaining means each security group references another security group's ID as its source, instead of an IP address. This way, if an instance's IP changes, the rule still works.
- In the AWS Console, navigate to VPC using the search bar at the top.
- In the left navigation pane, select Security groups.
- Click Create security group.
You'll create the ALB security group first. This is the outermost layer that accepts traffic from the internet.
- Enter three-tier-alb-sg in the Security group name field.
- Enter a description like Allow HTTP from internet.
- In the VPC dropdown, select your 10.0.0.0/16 VPC.
- Under Inbound rules, click Add rule.
- Set Type to HTTP.
- Set Source to Anywhere-IPv4 (0.0.0.0/0).
- Click Create security group.
What does this security group do?
The ALB security group allows inbound HTTP traffic on port 80 from any IP address on the internet. This is the only security group in your architecture that accepts traffic from the public.
Now create the App security group. This is where security group chaining comes in.
- Click Create security group again.
- Enter three-tier-app-sg in the Security group name field.
- Enter a description like Allow HTTP from ALB only.
- In the VPC dropdown, select your 10.0.0.0/16 VPC.
- Under Inbound rules, click Add rule.
- Set Type to HTTP.
- For Source, select Custom and type three-tier-alb-sg into the search box.
- Select your ALB security group from the dropdown. You'll see it populate with the security group ID.
- Click Create security group.
What is security group chaining?
Instead of allowing traffic from an IP address or CIDR block, you reference the ALB security group's ID as the source. This means only traffic that passes through the ALB can reach your app servers.
If your ALB's IP changes (which it does regularly), the rule still works. The rule says "only accept traffic from members of this security group" rather than "only accept traffic from this IP."
Can't find your ALB security group in the Source dropdown?
Make sure you selected the correct VPC for the App security group. Security groups from different VPCs won't appear in each other's source dropdowns.
help me find my ALB security group when creating the App security group
Create the Database security group and verify
The Database security group completes the chain. It only accepts MySQL traffic from the App security group, so only your app servers can ever reach the database.
- Click Create security group.
- Enter three-tier-db-sg in the Security group name field.
- Enter a description like Allow MySQL from App tier only.
- In the VPC dropdown, select your 10.0.0.0/16 VPC.
- Under Inbound rules, click Add rule.
- Set Type to MYSQL/Aurora. This automatically sets the port to 3306.
- For Source, select Custom and type three-tier-app-sg into the search box.
- Select your App security group from the dropdown.
- Click Create security group.
Why reference the App security group instead of an IP?
Your app servers sit in private subnets and get new private IPs whenever they restart. By referencing the App security group ID, any instance that belongs to that group can connect to the database, regardless of its current IP.
Now verify all three security groups exist using the CLI. You'll need your VPC ID, which you can find on the Your VPCs page in the VPC Console.
- Verify your security groups by running this command in your terminal:
aws ec2 describe-security-groups --filters Name=vpc-id,Values=[[VPC_ID="your VPC ID"]]
You should see all three security groups listed in the output: three-tier-alb-sg, three-tier-app-sg, and three-tier-db-sg. Check that the App security group's inbound rule references the ALB security group ID, and the Database security group's inbound rule references the App security group ID.
Only seeing one security group in the output?
The default security group always appears. If you only see one result, double-check that you pasted the correct VPC ID. You can find it on the Your VPCs page in the VPC Console.
help me verify my security groups with the AWS CLI
Create a custom NACL for the data subnets
Security groups are your first layer of defense. A Network ACL adds a second layer at the subnet boundary. It's stateless, meaning you must explicitly allow both inbound and outbound traffic.
- In the VPC Console left navigation pane, select Network ACLs.
- Click Create network ACL.
- Enter three-tier-data-nacl as the name.
- Select your 10.0.0.0/16 VPC.
- Click Create network ACL.
Now add the inbound rules. These allow only MySQL traffic from your app tier's CIDR range.
- Select your new three-tier-data-nacl from the list.
- Click the Inbound rules tab.
- Click Edit inbound rules.
- Click Add new rule.
- Set Rule number to 100.
- Set Type to Custom TCP.
- Set Port range to 3306.
- Set Source to 10.0.10.0/23.
- Set Allow/Deny to Allow.
- Click Save changes.
Why 10.0.10.0/23?
The /23 CIDR covers both of your private app subnets: 10.0.10.0/24 (AZ-a) and 10.0.11.0/24 (AZ-b). NACLs use CIDR blocks (not security group IDs), so this is the most precise range that covers your entire app tier.
Now add the outbound rules. NACLs are stateless, so you must explicitly allow return traffic on ephemeral ports.
- Click the Outbound rules tab.
- Click Edit outbound rules.
- Click Add new rule.
- Set Rule number to 100.
- Set Type to Custom TCP.
- Set Port range to 1024-65535.
- Set Destination to 10.0.10.0/23.
- Set Allow/Deny to Allow.
- Click Save changes.
What are ephemeral ports?
When your app server sends a MySQL request to port 3306, the database responds on a random high port (1024-65535) chosen by the operating system. These are called ephemeral ports. Without this outbound rule, the database can receive queries but can never send responses back.
Finally, associate the NACL with both data subnets.
- Click the Subnet associations tab.
- Click Edit subnet associations.
- Select both data subnets: the one with CIDR 10.0.20.0/24 and the one with CIDR 10.0.21.0/24.
- Click Save changes.
You should now see both data subnets listed under Subnet associations for your NACL. The default deny-all rule (rule *) is already in place for both inbound and outbound, blocking anything not explicitly allowed by rule 100.
Why use both security groups and NACLs?
This is called defense-in-depth. Security groups operate at the instance level and are stateful (return traffic is automatically allowed). NACLs operate at the subnet boundary and are stateless (you must explicitly allow return traffic).
If someone accidentally modifies a security group rule, the NACL still blocks unauthorized traffic to the database tier. Two independent layers are harder to misconfigure at the same time.
Don't see your data subnets in the association list?
Make sure you created the NACL in the same VPC as your subnets. Also confirm your data subnets use CIDRs 10.0.20.0/24 and 10.0.21.0/24. Each subnet can only be associated with one NACL at a time.
help me associate my NACL with my data subnets
Your security controls are locked in. Traffic can only flow ALB to App to Database, and nothing else. Next up, you'll deploy the RDS MySQL database inside those protected data subnets.
Deploy the RDS Database in the Data Tier
Your network is locked down with security groups and NACLs. Now it is time to deploy the actual database that your application will connect to.
In this step, you will launch an RDS MySQL database inside your isolated data subnets. The database will have zero internet exposure, accessible only from your app tier through the security group chain you built in the previous step.
In this step, get ready to:
- Create a DB Subnet Group that tells RDS which subnets to use.
- Launch a MySQL 8.0 RDS instance in the data tier.
- Verify the database has no public access.
Create a DB Subnet Group
RDS needs to know which subnets it is allowed to place your database in. A DB Subnet Group is a collection of subnets you designate for your database instances.
- Open the Amazon RDS console at https://console.aws.amazon.com/rds/.
- In the left navigation pane, click Subnet groups.
- Click Create DB subnet group.
- For Name, enter three-tier-data-subnet-group.
- For Description, enter Data tier subnets for RDS.
- For VPC, select the VPC you created earlier (the one with CIDR 10.0.0.0/16).
- In the Add subnets section, select both Availability Zones you used (e.g. us-east-1a and us-east-1b).
- For Subnets, select your two private data subnets (10.0.20.0/24 and 10.0.21.0/24).
- Click Create.
What is a DB Subnet Group?
A DB Subnet Group tells RDS which subnets it can use to place your database instance. RDS requires subnets in at least two Availability Zones so it can support Multi-AZ failover if needed later.
By pointing the group at your private data subnets only, the database can never accidentally land in a public subnet.
Your new subnet group should now appear in the list on the RDS console.
Don't see your subnet group?
Make sure you selected the correct VPC from the dropdown. If you chose the wrong VPC, the subnets from your project will not appear.
Also confirm you selected both Availability Zones before picking the subnets.
Help me troubleshoot my DB Subnet Group creation.
Launch the RDS MySQL instance
With the subnet group ready, you can now launch the actual database. You will use MySQL 8.0 on the smallest instance class to keep costs low.
- In the RDS console, click Databases in the left navigation pane.
- Click Create database.
- Select Standard create.
- For Engine type, select MySQL.
- For Engine version, select MySQL 8.0 (any 8.0.x minor version is fine).
- Under Templates, select Free tier if available for your account. Otherwise select Dev/Test.
- For DB instance identifier, enter a name like three-tier-db.
- For Master username, enter admin.
- For Master password, enter a strong password and note it down. You will need it later when connecting from the app server.
- For DB instance class, select Burstable classes and choose db.t3.micro.
- Under Availability & durability, select Single DB instance (this keeps costs low for this project).
- Under Connectivity, for VPC, select your project VPC.
- For DB subnet group, select three-tier-data-subnet-group.
- For Public access, select No.
- For VPC security group, select Choose existing and pick your Database security group (the one allowing port 3306 from the App security group).
- Remove the default security group if it was automatically added.
- Leave all other settings at their defaults and click Create database.
Why Single-AZ?
Multi-AZ deployments replicate your database to a standby instance in a second Availability Zone for automatic failover. It roughly doubles the cost.
For this learning project, Single-AZ keeps your costs low. Your subnet group still spans two AZs so you can enable Multi-AZ later with a single settings change.
The database will take 5-10 minutes to launch. You can monitor its status on the Databases page in the RDS console.
- Monitor creation status using the CLI by running this command:
aws rds describe-db-instances --db-instance-identifier [[DB_IDENTIFIER="your-db-instance-identifier"]] --query "DBInstances[0].DBInstanceStatus"
- Wait until the output shows "available". This confirms the database is ready.
Use the time wisely
While the database launches, review the security group chain you set up. Traffic flows: ALB (port 80) to App (port 80) to Database (port 3306). Each layer only accepts connections from the layer directly above it.
Database stuck in 'creating' for more than 15 minutes?
RDS instance creation can occasionally take longer depending on your region's load. As long as the status is not showing an error, be patient.
If you see an error status, check that your DB Subnet Group contains subnets in at least two different Availability Zones.
Help me troubleshoot my RDS instance creation.
Verify the database has no public access
Once the database status shows as available, you need to confirm it has zero internet exposure. This is the entire point of placing it in isolated data subnets with no internet route.
- In the RDS console, click on your database instance name to open its details.
- Under Connectivity & security, note the Endpoint value. Copy this somewhere safe. You will need it when configuring the app server in the next step.
- Verify the database has no public IP by running this command:
aws rds describe-db-instances --db-instance-identifier [[DB_IDENTIFIER="your-db-instance-identifier"]] --query "DBInstances[0].PubliclyAccessible"
- The output should show false. This confirms the database is only accessible from within the VPC.
What does PubliclyAccessible: false mean?
When PubliclyAccessible is false, the database DNS name resolves to a private IP address only. Even if someone had the endpoint URL, they could not reach it from the internet.
Combined with the Database security group (only allows port 3306 from the App security group) and the data tier NACL (only allows port 3306 from 10.0.10.0/23), this database has three layers of protection.
Seeing true instead of false?
If PubliclyAccessible shows true, you selected Yes for Public access during creation. You can fix this by modifying the instance: in the RDS console, click Modify, scroll to Connectivity, expand Additional configuration, set Public access to No, and apply immediately.
Help me fix my RDS public access setting.
Your database is deployed and locked down. Next up, you will deploy the application server in the private app subnet and connect it to this database through the security group chain.
Deploy the Application on EC2
Your database is running in a fully isolated data subnet. Now you need an application to talk to it.
In this step, you will launch an EC2 instance in your private app subnet, connect to it through a bastion host, and deploy a simple Node.js API that proves the full traffic chain works.
In this step, get ready to:
- Launch an EC2 app server in a private subnet and configure SSH access.
- Set up a bastion host and SSH into the private app server.
- Deploy and test a Node.js Express API that connects to your RDS database.
Launch the EC2 app server in a private subnet
Your app server needs to live in the private app subnet where it can reach the database but not be directly exposed to the internet. You also need SSH access for setup, so you will create a key pair and temporarily allow SSH from your public subnet.
- In the AWS Console, navigate to EC2 and select Key Pairs from the left sidebar under Network & Security.
- Click Create key pair.
- Enter a name like three-tier-key.
- Select .pem as the file format (for macOS/Linux) or .ppk (for Windows with PuTTY).
- Click Create key pair. The file downloads automatically. Save it somewhere you can find it.
Why create a key pair?
EC2 instances use SSH key pairs instead of passwords for authentication. The private key stays on your local machine. The public key gets placed on the instance at launch. You will use this key to SSH into both your bastion and your app server.
- Now navigate to Instances in the left sidebar and click Launch instances.
- Set the name to App-Server.
- Under Application and OS Images, select Amazon Linux 2023 AMI (it should be the default).
- Under Instance type, select t3.micro.
- Under Key pair, select the three-tier-key you just created.
- Under Network settings, click Edit.
- Select your custom VPC.
- For Subnet, choose private-app-subnet-a (10.0.10.0/24 in AZ-a).
- Set Auto-assign public IP to Disable.
- Under Firewall (security groups), select Select existing security group and choose your App security group.
- Click Launch instance.
Now you need to update the App security group to allow SSH from the public subnet so the bastion can reach it.
- Navigate to Security Groups in the EC2 sidebar.
- Select your App security group.
- Click the Inbound rules tab, then Edit inbound rules.
- Click Add rule.
- Set Type to SSH, Port to 22, and Source to 10.0.1.0/24 (your public subnet CIDR).
- Click Save rules.
Why SSH from the public subnet CIDR?
Your app server is in a private subnet with no public IP. The only way to reach it is through the bastion host sitting in the public subnet. By restricting SSH access to 10.0.1.0/24, only instances in that public subnet can initiate an SSH connection to your app server.
Don't see your VPC or subnet in the dropdown?
Make sure you are in the same AWS region where you created your VPC. Check the region selector in the top right corner of the Console.
help me find my VPC when launching an EC2 instance
Set up the bastion host and SSH into the app server
A bastion host is a dedicated instance in a public subnet that acts as a secure gateway to your private instances. You will SSH into the bastion first, then hop from the bastion into your private app server.
- First, create a security group for the bastion. Navigate to Security Groups and click Create security group.
- Name it Bastion-SG and select your VPC.
- Add an inbound rule: Type SSH, Port 22, Source set to My IP (the Console auto-fills your current public IP).
- Click Create security group.
- Navigate back to Instances and click Launch instances.
- Name it Bastion-Host.
- Select Amazon Linux 2023 AMI and t3.micro instance type.
- Select your three-tier-key key pair.
- Under Network settings, click Edit. Select your VPC and choose public-subnet-a (10.0.1.0/24 in AZ-a).
- Ensure Auto-assign public IP is Enable.
- Select Select existing security group and choose Bastion-SG.
- Click Launch instance.
Wait for both instances to show Running status in the Instances list. Note the bastion's Public IPv4 address and the app server's Private IPv4 address.
What is a bastion host?
A bastion host is a purpose-built instance that serves as your single entry point into private subnets. It sits in a public subnet so you can reach it from the internet, but its only job is to relay SSH connections inward. Your private key never leaves your local machine thanks to SSH agent forwarding.
Now SSH into the bastion, then hop to the app server. On your local machine, open a terminal and run the following commands.
- Add your key to the SSH agent by running:
eval $(ssh-agent)
ssh-add /path/to/three-tier-key.pem
- Connect to the bastion host with agent forwarding enabled by running:
ssh -A ec2-user@[[BASTION_IP="your bastion public IPv4 address"]]
What does the -A flag do?
The -A flag enables SSH agent forwarding. It lets the bastion relay authentication requests back to your local machine. This means you can SSH from the bastion into private instances without ever copying your private key onto the bastion.
- Once connected to the bastion, SSH into your private app server by running:
ssh ec2-user@[[APP_SERVER_IP="your app server private IPv4 address"]]
You should see the Amazon Linux 2023 welcome message. You are now inside your private app server.
Connection timed out or permission denied?
If you get a timeout, verify that your App security group has the SSH rule allowing port 22 from 10.0.1.0/24. If you get permission denied, make sure you ran ssh-add with the correct key file before connecting with -A.
help me troubleshoot SSH to my private EC2 instance through a bastion host
Install Node.js and deploy the Express API
With SSH access established, you can now install Node.js and deploy a simple health check API. This API connects to your RDS MySQL database and confirms the entire security group chain is working.
- On the app server (you should still be SSH'd in), install Node.js by running:
sudo dnf install -y nodejs20 nodejs20-npm
Why nodejs20?
Amazon Linux 2023 uses namespaced Node.js packages. The nodejs20 package installs Node.js 20, which provides full ES module support and a modern npm. The alternatives system automatically sets up the node and npm commands for you.
- Verify Node.js is installed by running:
node --version
You should see a version number starting with v20.
- Create a directory for your application and move into it by running:
mkdir ~/app && cd ~/app
- Initialize a Node.js project and install the required packages by running:
npm init -y
npm install express mysql2
What are these packages?
The express package is a lightweight web framework for building HTTP APIs. The mysql2 package is a MySQL client that connects to your RDS instance. Together, they create a minimal API that proves your app tier can reach the data tier.
- Create the application file by running:
cat << 'EOF' > server.js
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
const port = 80;
// Database connection configuration
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
connectTimeout: 5000
};
// Health check endpoint
app.get('/', async (req, res) => {
let dbStatus = 'disconnected';
try {
const connection = await mysql.createConnection(dbConfig);
await connection.ping();
dbStatus = 'connected';
await connection.end();
} catch (err) {
dbStatus = `error: ${err.message}`;
}
res.json({
status: 'healthy',
instance: require('os').hostname(),
database: dbStatus,
timestamp: new Date().toISOString()
});
});
app.listen(port, '0.0.0.0', () => {
console.log(`Server running on port ${port}`);
});
EOF
What does this code do?
This Express API has a single health check endpoint at the root path /. When hit, it attempts a connection to the RDS database using environment variables for credentials. It returns a JSON response with the server status, hostname, database connectivity result, and a timestamp.
The hostname in the response becomes useful later if you add Auto Scaling. Different instances will report different hostnames, proving the load balancer distributes traffic.
- Start the application with your RDS endpoint and credentials by running:
sudo DB_HOST=[[RDS_ENDPOINT="your-rds-endpoint.region.rds.amazonaws.com"]] DB_USER=[[DB_USER="your-master-username"]] DB_PASS=your-master-password node server.js
Find your RDS endpoint
You noted this endpoint in the previous step. You can also find it in the RDS Console by selecting your database instance and looking at the Endpoint value under Connectivity & security.
You should see the message Server running on port 80 in the terminal output.
- Open a second SSH session to your app server (bastion hop again) and test the API by running:
curl http://localhost
You should see a JSON response similar to this:
{
"status": "healthy",
"instance": "ip-10-0-10-XXX",
"database": "connected",
"timestamp": "2026-05-18T12:00:00.000Z"
}
The "database": "connected" confirms that your app server in the private app subnet can reach RDS in the data subnet through the security group chain you built.
Seeing database error in the response?
If the database field shows an error instead of connected, check the following:
- Verify your RDS endpoint is correct and the instance status is available.
- Confirm the Database security group allows inbound port 3306 from the App security group ID.
- Check your NACL on the data subnets allows TCP 3306 inbound from 10.0.10.0/23 and ephemeral ports outbound to the same range.
- Make sure the DB username and password match what you set during RDS creation.
help me debug why my EC2 app cannot connect to RDS through security groups
Your application tier is live and proven. The security group chain works end-to-end. Next up, you will put an Application Load Balancer in front of this instance so users on the internet can reach your API without ever touching the private subnet directly.
Set Up the Application Load Balancer
Your app server is running in a private subnet, responding to requests and connecting to the database. But there is a problem. Users on the internet cannot reach a private subnet directly.
An Application Load Balancer solves this by sitting in the public subnets and forwarding traffic to your private app server. In this step, you will create the ALB, register your EC2 instance as a target, and prove the full traffic flow from the internet all the way through to your database.
In this step, get ready to:
- Create an Application Load Balancer and target group.
- Configure the listener and verify target health.
- Test the full traffic flow from your browser to the database.
Create the ALB and target group
The ALB needs two things to work: a load balancer in the public subnets to receive internet traffic, and a target group that tells it where to forward that traffic. You will create both now.
Start by creating the target group so it is ready when you configure the ALB.
- In the AWS Console, open the EC2 service.
- In the left navigation pane under Load Balancing, click Target Groups.
- Click Create target group.
- For Choose a target type, select Instances.
- Enter a name for your target group (e.g. three-tier-app-tg).
- Set Protocol to HTTP and Port to 80.
- Select your project VPC from the VPC dropdown.
- In the Health checks section, set the Health check path to match your app's health endpoint (e.g. / or /health, depending on what your Express API responds to).
- Click Next.
- On the Register targets page, select your app server EC2 instance from the list.
- Click Include as pending below.
- Click Create target group.
Why create the target group first?
When you create the ALB, you need to select a target group for the listener's default action. Creating the target group first means it will appear in the dropdown during ALB creation.
Now create the Application Load Balancer itself.
- In the left navigation pane, click Load Balancers.
- Click Create load balancer.
- Under Application Load Balancer, click Create.
- Enter a name for your load balancer (e.g. three-tier-alb).
- For Scheme, select Internet-facing.
- For IP address type, keep IPv4.
- In the Network mapping section, select your project VPC.
- Select both public subnets (public-subnet-a in AZ-a and public-subnet-b in AZ-b).
- In the Security groups section, remove the default security group and select your ALB security group (the one allowing inbound HTTP on port 80 from 0.0.0.0/0).
- In the Listeners and routing section, keep the default listener on HTTP port 80.
- For Default action, select the target group you just created.
- Scroll to the bottom and click Create load balancer.
What is happening here?
The ALB sits in both public subnets so it has internet access from two Availability Zones. When a user sends a request to the ALB's DNS name, the ALB forwards it to the target group, which routes it to your EC2 instance in the private app subnet.
The listener on port 80 is the rule that says "take incoming HTTP traffic and forward it to this target group." The ALB security group allows that inbound traffic, and your App security group only accepts traffic from the ALB security group. This is security group chaining in action.
You should see a success message confirming your load balancer was created. It will take a minute or two to become active.
Don't see your VPC or subnets?
Make sure you are in the correct AWS region (same region where you created your VPC). If the subnets do not appear, confirm that auto-assign public IP is enabled on both public subnets.
Help me troubleshoot why my subnets aren't appearing in the ALB creation form.
Wait for the target to become healthy
The ALB performs health checks against your app before it sends traffic. If the health check path does not match what your Express API responds to, the target will stay unhealthy and the ALB will not forward traffic.
- In the left navigation pane under Load Balancing, click Target Groups.
- Click on the name of your target group to open its details page.
- Click the Targets tab.
- Check the Health status column for your registered instance.
- Wait until the status changes to healthy. This can take 30 seconds to a few minutes.
What does healthy mean?
The ALB sends HTTP requests to your health check path (e.g. / or /health) on port 80. If the target responds with a 200 status code, the ALB marks it as healthy and starts sending real traffic to it.
Target stuck in unhealthy status?
Check that the health check path in your target group matches a route your Express API actually responds to. If your app responds at / but the health check is set to /health, the ALB will get a 404 and mark the target as unhealthy.
Also confirm your app is still running on the EC2 instance. SSH back in through the bastion and verify the process is active.
Help me debug why my ALB target is stuck in unhealthy status.
Verify the full traffic flow from your browser
This is the moment of truth. You will access the ALB's public DNS name from your browser and see the response travel through all three tiers of your architecture.
- Go back to Load Balancers in the EC2 Console.
- Select your ALB and find its DNS name in the Description section (it looks like three-tier-alb-123456789.us-east-1.elb.amazonaws.com).
- Copy the DNS name.
- Paste it into your browser's address bar and press Enter.
You should see your API's health check JSON response in the browser. This confirms the full traffic flow: Internet > ALB (public subnets) > EC2 (private app subnet) > RDS (private data subnet) and back.
What just happened?
Your browser sent an HTTP request to the ALB's public DNS. The ALB received it in the public subnet, forwarded it to your EC2 instance in the private app subnet (allowed by security group chaining), and your app queried RDS in the isolated data subnet. The response traveled back through the same chain.
No component talks to anything it should not. The database never sees the internet. The app server never receives traffic from anywhere except the ALB.
Now verify the target health using the CLI to confirm everything programmatically.
- Verify your target is healthy by running this command (replace the ARN with your target group's ARN from the Console):
aws elbv2 describe-target-health --target-group-arn [[TG_ARN="your target group ARN from the Target Groups page"]]
What does this command do?
The aws elbv2 describe-target-health command queries the health status of all targets registered in your target group. You should see a response showing your EC2 instance with a State of healthy and the HealthCheckPort as 80.
Seeing an error about the ARN?
Go to Target Groups in the EC2 Console, select your target group, and copy the full ARN from the details panel. It starts with arn:aws:elasticloadbalancing:.
Help me find my target group ARN for the describe-target-health command.
Your 3-tier architecture is fully operational. Traffic flows securely from the internet through isolated network tiers, with every hop enforced by security groups. Next up, you will tackle the Secret Mission and add Auto Scaling to make your app tier resilient.
Secret mission
Add Auto Scaling to the App Tier
Your app is running on a single EC2 instance. In production, that is a single point of failure. Add Auto Scaling to make your app tier resilient and horizontally scalable.
Clean Up Your Resources
Clean Up Your Resources
This project provisions several AWS resources that cost money while running. The NAT Gateway and Application Load Balancer are never Free Tier eligible. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
If left running, this architecture costs approximately $3-5 per day. The NAT Gateway alone costs $0.045/hour (~$1.08/day), and the Application Load Balancer adds ~$0.54/day.
If you are done with this project, delete all resources immediately to avoid unnecessary charges. Follow the Delete tab instructions below.
Resources you used:
- NAT Gateway and Elastic IP.
- EC2 instances (app server and bastion host).
- RDS MySQL instance.
- Application Load Balancer and Target Group.
- VPC and networking resources (subnets, route tables, Internet Gateway, security groups, NACLs).
- EC2 key pair.
Keep everything running
No action needed. Choose this if you are still actively building or plan to use this architecture as a foundation for later projects.
- Expect approximately $3-5/day in charges while all resources are running.
- Consider stopping EC2 and RDS instances when not actively using them to reduce costs (you still pay for storage and the Elastic IP).
- The NAT Gateway continues to cost $0.045/hour even when no traffic flows through it.
Pause - I'll come back to this later
Shut down running compute resources and remove the NAT Gateway to reduce costs to near zero. You keep all networking configuration and can restart when ready.
- Stop both EC2 instances (app server and bastion) from the EC2 Console. Select each instance, then choose Instance state > Stop instance.
- Stop the RDS instance from the RDS Console. Select your database, then choose Actions > Stop temporarily.
- Delete the NAT Gateway from the VPC Console under NAT gateways. Select it and choose Actions > Delete NAT gateway.
- Release the Elastic IP from the VPC Console under Elastic IPs. Select the address and choose Actions > Release Elastic IP addresses.
What still costs money when paused?
Stopped EC2 instances still incur charges for their EBS volumes (~$0.08/GB/month). Stopped RDS instances still incur storage charges. RDS will automatically restart after 7 days if you do not stop it again.
Delete - I don't want to use this again
Remove all project resources completely. Delete them in this specific order to avoid dependency errors.
- Delete the Application Load Balancer. In the EC2 Console, go to Load Balancers. Select your ALB, then choose Actions > Delete load balancer.
- Delete the Target Group. In the EC2 Console, go to Target Groups. Select your target group and choose Actions > Delete.
- If you completed the Secret Mission, delete the Auto Scaling Group first. In the EC2 Console, go to Auto Scaling Groups. Select your ASG and choose Delete.
- Terminate all EC2 instances (app server and bastion). In the EC2 Console, select each instance and choose Instance state > Terminate instance.
- Delete the RDS instance. In the RDS Console, select your database and choose Actions > Delete. Uncheck Create final snapshot if this is just practice. Check the acknowledgment box and confirm.
- Delete the DB Subnet Group. In the RDS Console, go to Subnet groups. Select your subnet group and choose Delete.
- Delete the NAT Gateway. In the VPC Console, go to NAT gateways. Select it and choose Actions > Delete NAT gateway. Wait a few minutes for it to reach the Deleted state.
- Release the Elastic IP. In the VPC Console, go to Elastic IPs. Select the address and choose Actions > Release Elastic IP addresses.
- Detach and delete the Internet Gateway. In the VPC Console, go to Internet gateways. Select your IGW, choose Actions > Detach from VPC, then Actions > Delete internet gateway.
- Delete all 6 subnets. In the VPC Console, go to Subnets. Filter by your VPC, select all project subnets, and choose Actions > Delete subnet.
- Delete the custom route tables (public, private, and data). In the VPC Console, go to Route tables. Select each custom route table and choose Actions > Delete route table. Do not delete the Main route table (it deletes with the VPC).
- Delete the custom security groups (ALB, App, Database, Bastion). In the VPC Console, go to Security groups. Select each custom group and choose Actions > Delete security groups. Do not delete the default security group (it deletes with the VPC).
- Delete the custom NACL. In the VPC Console, go to Network ACLs. Select your custom NACL and choose Actions > Delete network ACL.
- Delete the VPC. In the VPC Console, go to Your VPCs. Select your project VPC and choose Actions > Delete VPC.
- Delete the key pair. In the EC2 Console, go to Key Pairs. Select your key pair and choose Actions > Delete. Also delete the .pem file from your local machine.
Faster alternative
If you delete the ALB, EC2 instances, and RDS instance first, you can then select your VPC in the Console and choose Actions > Delete VPC. The Console will show remaining dependencies and offer to delete them for you.
That's a Wrap!
That's a Wrap!
You just deployed a production-grade 3-tier architecture on AWS. This is how companies actually run their applications in production.
Here's what you accomplished:
- Built a custom VPC with 6 subnets across 2 Availability Zones, enforcing network isolation between web, app, and data tiers.
- Implemented security group chaining and NACLs for defense-in-depth, ensuring each tier can only communicate with its direct neighbors.
- Deployed a full traffic flow from an Application Load Balancer through a private EC2 app server to an isolated RDS MySQL database with zero internet exposure.
- Secret Mission: Added Auto Scaling to the app tier for resilience and horizontal scaling capability.
This architecture is the exact foundation you will containerize with Docker and automate with CI/CD pipelines in future projects.
Ready to quiz yourself?