DEV Community

Cover image for How I Deployed a Production-Ready Three-Tier Web App on AWS
Vivian Chiamaka Okose
Vivian Chiamaka Okose

Posted on

How I Deployed a Production-Ready Three-Tier Web App on AWS

How I Deployed a Production-Ready Three-Tier Web Application on AWS — From Scratch

Introduction

As part of my DevOps training capstone project, I recently completed one of the most challenging and rewarding hands-on projects I have ever worked on: deploying a full-stack Book Review App on AWS using a real-world, production-grade three-tier architecture.

This is not a tutorial on blindly clicking through the AWS console. This is an honest account of what I designed, what broke, why it broke, and how I fixed it — because that is where the real learning happens.

By the end of this post, you will understand:

  • What a three-tier architecture is and why it matters
  • How to design a secure AWS VPC with public and private subnets
  • How to connect all three tiers securely using Application Load Balancers
  • The real-world problems I ran into and how I solved them

What Is a Three-Tier Architecture?

A three-tier architecture separates a web application into three distinct layers, each with a specific responsibility:

Tier Layer Technology Used
Web Tier Presentation Layer Next.js + Nginx on EC2
App Tier Business Logic Layer Node.js/Express on EC2
Database Tier Data Layer MySQL on Amazon RDS

The key principle is isolation — each tier lives in its own network layer and can only communicate with the tier directly above or below it. This makes the system more secure, more scalable, and easier to maintain.


The Architecture I Built

Here is the complete picture of what I deployed:

INTERNET
    │
    ▼
[Public Application Load Balancer]
    │
    ▼
[Web Tier EC2 — Next.js + Nginx]  ← Public Subnet (us-east-1a)
    │
    ▼
[Internal Application Load Balancer]
    │
    ▼
[App Tier EC2 — Node.js/Express]  ← Private Subnet (us-east-1a)
    │
    ▼
[Amazon RDS MySQL 8.4 + Read Replica]  ← Private Subnet (us-east-1a/1b)
Enter fullscreen mode Exit fullscreen mode

Everything lives inside a custom VPC (10.0.0.0/16) with 6 subnets across 2 Availability Zones for high availability.


Phase 1 — VPC and Network Design

The foundation of everything is the network. I created a custom VPC with the following subnets:

Subnet CIDR Type AZ
web-subnet-1 10.0.1.0/24 Public us-east-1a
web-subnet-2 10.0.2.0/24 Public us-east-1b
app-subnet-1 10.0.3.0/24 Private us-east-1a
app-subnet-2 10.0.4.0/24 Private us-east-1b
db-subnet-1 10.0.5.0/24 Private us-east-1a
db-subnet-2 10.0.6.0/24 Private us-east-1b

Public subnets have a route to the Internet Gateway, and this is where the Web Tier EC2 lives and where traffic from the Internet enters.

Private subnets have no route to the internet; this is where the App Tier and Database Tier live, completely isolated from direct public access.


Phase 2 — Security Groups

Security groups act as virtual firewalls. I used a least-privilege approach — each tier only accepts traffic from the tier directly above it:

Web Security Group (web-sg):

  • Inbound: HTTP port 80, HTTPS port 443 from the internet (0.0.0.0/0)
  • Inbound: SSH port 22 from the internet (for initial setup)

App Security Group (app-sg):

  • Inbound: TCP port 3001 from web-sg only
  • Inbound: HTTPS port 443 from VPC CIDR (for SSM VPC Endpoints)

Database Security Group (db-sg):

  • Inbound: MySQL port 3306 from app-sg only

This means the database is completely unreachable from the internet or the web tier — only the app tier can query it.


Phase 3 — RDS MySQL Setup

I created an Amazon RDS MySQL 8.4 instance in the private database subnets with:

  • A DB Subnet Group spanning both database subnets across 2 AZs
  • Public access disabled — the database has no public IP
  • SSL enabled — all connections to the database are encrypted
  • A Read Replica in us-east-1b for read scalability

The connection string used by the backend looks like this:

DB_HOST=book-review-db.cgdwgcgy8hsp.us-east-1.rds.amazonaws.com
DB_NAME=bookreviews
DB_USER=admin
DB_PASS=<password>
DB_PORT=3306
Enter fullscreen mode Exit fullscreen mode

Phase 4 — App Tier EC2 Setup

The App Tier EC2 instance runs the Node.js/Express backend in a private subnet with no public IP address. This raises an interesting question — how do you connect to a server that has no public IP?

The Answer: SSM Session Manager

Instead of SSH, I used AWS Systems Manager (SSM) Session Manager to get terminal access to the private instance. This is more secure than SSH because:

  • No open SSH ports required
  • No key pair management
  • All session activity is logged in AWS CloudTrail

To make SSM work in a private subnet (no internet access), I created three VPC Interface Endpoints:

com.amazonaws.us-east-1.ssm
com.amazonaws.us-east-1.ssmmessages
com.amazonaws.us-east-1.ec2messages
Enter fullscreen mode Exit fullscreen mode

These endpoints allow the SSM agent on the EC2 instance to communicate with AWS services without needing internet access.

Installing Software in a Private Subnet

Here is something that catches many beginners off guard — a private subnet has no internet access by default. When I tried to run apt update, it failed:

Err: Cannot initiate the connection to us-east-1.ec2.archive.ubuntu.com:80
Network is unreachable
Enter fullscreen mode Exit fullscreen mode

The fix was to create a NAT Gateway in the public subnet and add a route in the private route table:

0.0.0.0/0 → NAT Gateway
Enter fullscreen mode Exit fullscreen mode

The NAT Gateway allows instances in private subnets to make outbound internet requests (for downloading packages) while remaining unreachable from the internet.

After that, apt update, npm install, and git clone all worked perfectly.

Starting the Backend with PM2

I used PM2 to keep the Node.js backend running as a persistent background process:

pm2 start npm --name "backend" -- start
pm2 startup
pm2 save
Enter fullscreen mode Exit fullscreen mode

PM2 ensures the backend automatically restarts if the server reboots or the process crashes.


Phase 5 — Web Tier EC2 Setup

The Web Tier EC2 runs in a public subnet with a public IP address. I installed:

  • Node.js 18.x — for running the Next.js frontend
  • Nginx — as a reverse proxy sitting in front of Next.js
  • PM2 — to keep the frontend process running persistently

Nginx as a Reverse Proxy

Nginx listens on port 80 and forwards requests to the Next.js app running on port 3000:

server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    location /api/ {
        proxy_pass http://internal-alb-dns:3001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice the /api/ block — this is crucial, and I will explain why in the troubleshooting section.


Phase 6 — Load Balancers

I created two Application Load Balancers:

Public ALB — Internet-facing, routes traffic from the internet to the Web Tier EC2 instances.

Internal ALB — Internal only, routes traffic from the Web Tier to the App Tier EC2 instances. It is completely unreachable from the internet.

Each ALB has a Target Group that registers the EC2 instances and performs health checks to ensure traffic only goes to healthy instances.


The Troubleshooting Journey

This is the most valuable part of the post. Here are the real problems I hit and how I solved each one.


Problem 1 — SSM Session Manager Offline in Private Subnet

Symptom: The App Tier instance showed "Ping Status: Offline" in SSM.

Root Cause: The SSM agent needs to reach AWS endpoints over the internet to register. The private subnet has no internet access.

Fix: Created three VPC Interface Endpoints for ssm, ssmmessages, and ec2messages. These gave the SSM agent a private path to AWS services without needing internet access.


Problem 2 — No Internet Access in Private Subnet

Symptom: apt update failed with "Network is unreachable" on the App Tier.

Root Cause: Private subnets have no route to the internet by default.

Fix: Created a NAT Gateway in web-subnet-1 (public) and added 0.0.0.0/0 → NAT Gateway to the private route table. This gives private instances outbound internet access for downloading packages.


Problem 3 — ALB Health Checks Failing on App Tier

Symptom: app-tier-tg showed 1 unhealthy target even though the backend was running perfectly on localhost:3001.

Root Cause: The app-sg inbound rule for port 3001 only allowed traffic from web-sg. ALB health check requests originate from the ALB itself, not from web-sg, so they were being blocked.

Fix: Added inbound rule to app-sg allowing port 3001 from 10.0.0.0/16 (the entire VPC CIDR).


Problem 4 — Books Not Loading in Browser (ERR_CONNECTION_TIMED_OUT)

Symptom: The app loaded but showed "No books available". The browser network tab showed ERR_CONNECTION_TIMED_OUT on the books API call.

Root Cause: This was the most interesting problem. In Next.js, environment variables prefixed with NEXT_PUBLIC_ are client-side variables. This means the browser itself makes the API call — not the server. The NEXT_PUBLIC_API_URL was set to the Internal ALB DNS, which the browser was trying to reach directly from outside the VPC. Since the Internal ALB is private, the browser could never reach it.

Fix: Set API_URL to an empty string so all API calls go to the same origin (the Public ALB). Then configured Nginx to proxy all /api/* requests to the Internal ALB internally on the server side. The browser never needs to know the Internal ALB exists.

location /api/ {
    proxy_pass http://internal-book-review-internal-alb.us-east-1.elb.amazonaws.com:3001;
}
Enter fullscreen mode Exit fullscreen mode

Problem 5 — CORS Errors on Registration and Login

Symptom: Registration and login failed with "Registration failed" in the browser. Backend logs showed Error: CORS policy: Not allowed by server.

Root Cause: The backend CORS configuration only allowed http://localhost:3000 as an origin. Requests coming from the Public ALB DNS were being rejected.

Fix: Updated ALLOWED_ORIGINS in the backend .env to include the Public ALB DNS:

ALLOWED_ORIGINS=http://book-review-public-alb-169792600.us-east-1.elb.amazonaws.com,http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

The Final Result

After working through every one of those problems, the full application was live and working end-to-end:

✅ Books list loading from RDS MySQL via the full stack
✅ User registration and JWT authentication working
✅ Book reviews are being saved to and retrieved from the database
✅ All traffic flows securely through the load balancers
✅ No direct public access to the App Tier or Database Tier

Live App: http://book-review-public-alb-169792600.us-east-1.elb.amazonaws.com
GitHub Repo: https://github.com/vivianokose/book-review-app


Key Lessons Learned

1. Private subnets need NAT Gateways for outbound internet access.
This is easy to forget when setting up private instances for the first time. Always plan your NAT Gateway before launching private EC2 instances.

2. SSM Session Manager is superior to SSH for private instances.
No bastion hosts, no key management, full audit logging. Once you use SSM, you will never want to go back to SSH.

3. NEXT_PUBLIC_ variables are client-side — not server-side.
This is a critical distinction in Next.js. Any variable prefixed with NEXT_PUBLIC_ will be bundled into the browser JavaScript. Never put internal infrastructure endpoints in client-side variables.

4. Always check security group sources when ALB health checks fail.
ALB health checks come from the ALB itself, not from other security groups. Always allow the VPC CIDR on health check ports, not just the upstream security group.

5. Troubleshooting is the job.
Every problem I hit in this project is a problem that exists in real production environments. Learning to diagnose and fix these issues systematically is the most important skill a Cloud or DevOps Engineer can have.


What I Would Do Differently

If I were to rebuild this project, I would:

  • Use Terraform to provision all infrastructure as code
  • Add HTTPS with an ACM SSL certificate on the Public ALB
  • Set up CloudWatch alarms for CPU, memory, and RDS connections
  • Configure Auto Scaling Groups for the Web and App Tier instances
  • Store secrets in AWS Secrets Manager instead of .env files

Conclusion

This project gave me genuine hands-on experience with AWS networking, compute, database management, load balancing, and security — the core pillars of cloud infrastructure engineering.

If you are learning DevOps or Cloud Engineering, I highly recommend building a project like this. Do not just watch tutorials; also try to deploy something, break something, and fix it. That is where the real learning happens.

Feel free to connect with me on LinkedIn or check out the full project on GitHub. I would love to hear your feedback!


AWS, DevOps, Cloud Computing, Three-Tier Architecture, EC2, RDS, ALB, VPC, Nginx, Node.js, Next.js, Beginners

Top comments (0)