AWS 📖 22 min read
📅 Published: 🔄 Updated:

Build a 3-Tier VPC Architecture

Last March I was tailing VPC Flow Logs on a staging account when I noticed something wrong. Our app server in 10.0.0.0/24 was sending SYN packets to port 3306 on every IP in the subnet — including the RDS instance that was sitting right there on the same flat network. Someone had gotten a shell on the app server through a dependency vulnerability, and because there was nothing between the app tier and the database tier, they were scanning freely. No NACLs, no segmentation, one security group that allowed all internal traffic. I killed the instance, rotated every credential in that account, and spent the next weekend rebuilding the whole network from scratch. This is the architecture I built to replace that flat network.

🛠️ Before You Start

💻
Hardware Any computer with a web browser
📦
Software AWS account (Free Tier works), AWS CLI optional
⏱️
Estimated Time 30-60 minutes

Lessons from the breach:

  1. A flat network with a single permissive security group means that any compromised instance has direct access to every other instance, including your database — subnet isolation is not optional, it is the primary control.
  2. NACLs on the data subnet should explicitly deny all traffic except the specific ports from the specific app-tier CIDR, because security groups alone can be modified by anyone with EC2 permissions and you need a second layer that requires VPC-level access to change.
  3. VPC Flow Logs with a CloudWatch alarm on rejected packets from unexpected source CIDRs would have caught the lateral movement hours earlier, before the attacker had time to enumerate the database.

This is the standard pattern for web applications:

Architecture Overview
 Internet
 ↓
 [ Internet Gateway ]
 ↓
 ┌─────────────────┴──────────────────┐
 ↓ ↓
 [ ALB in Public Subnet AZ-a ] [ ALB in Public Subnet AZ-b ]
 ↓ ↓
 └────────────┬───────────────────────┘
 ↓
 ┌────────────┴────────────┐
 ↓ ↓
 [ App Server ] [ App Server ]
 Private Subnet AZ-a Private Subnet AZ-b
 ↓ ↓
 └────────────┬────────────┘
 ↓
 ┌────────────┴────────────┐
 ↓ ↓
 [ Database ] [ Database Standby ]
 Private Subnet AZ-a Private Subnet AZ-b

Three tiers:

  1. Public tier — Load balancer, NAT gateways. Internet-accessible.
  2. Private app tier — Application servers. No direct internet access.
  3. Private data tier — Databases. Completely isolated.

Step 1: Create the VPC

  1. Go to VPC Console
  2. Click Create VPC
  3. Select VPC only (not VPC and more — we'll add subnets manually)
  4. Name: production-vpc
  5. IPv4 CIDR: 10.0.0.0/16 (65, 536 IP addresses)
  6. Click Create

Enable DNS hostnames (needed for RDS and some other services):

  1. Select your VPC
  2. Actions → Edit VPC settings
  3. Enable DNS hostnames
  4. Save

Step 2: Create Subnets

We need 6 subnets: 2 public, 2 private app, 2 private data, spread across 2 availability zones.

  1. VPC → Subnets → Create subnet
  2. Select your VPC

Create these subnets:

Name CIDR AZ Type
public-subnet-1a 10.0.1.0/24 us-east-1a Public
public-subnet-1b 10.0.2.0/24 us-east-1b Public
private-app-1a 10.0.10.0/24 us-east-1a Private
private-app-1b 10.0.11.0/24 us-east-1b Private
private-data-1a 10.0.20.0/24 us-east-1a Private
private-data-1b 10.0.21.0/24 us-east-1b Private

The CIDR ranges are just a convention. The key is: don't overlap, and leave room for future expansion.

Step 3: Internet Gateway

Terminal: SSH login to server
Terminal: SSH login to server

An internet gateway attaches to your VPC and routes traffic to/from the public internet. One per VPC, no bandwidth limits, no hourly charge.

  1. VPC → Internet gateways → Create internet gateway
  2. Name: production-igw
  3. Create
  4. Select it → Actions → Attach to VPC → Select your VPC

Step 4: Route Tables

Two route tables: public subnets get a default route to the internet gateway, private subnets get a default route to the NAT gateway.

Public Route Table

  1. VPC → Route tables → Create route table
  2. Name: public-rt
  3. VPC: Select your VPC
  4. Create

Add internet route:

  1. Select public-rt → Routes tab → Edit routes
  2. Add route: 0.0.0.0/0 → Target: Internet Gateway → Select your IGW
  3. Save

Associate with public subnets:

  1. Subnet associations tab → Edit subnet associations
  2. Select public-subnet-1a and public-subnet-1b
  3. Save

Enable Auto-assign Public IP

Public subnets need auto-assigned public IPs:

  1. Subnets → Select public-subnet-1a
  2. Actions → Edit subnet settings
  3. Enable auto-assign public IPv4 address
  4. Save
  5. Repeat for public-subnet-1b

Step 5: NAT Gateway

Private subnet instances cannot reach the internet directly. They still need outbound access for OS updates and external API calls. A NAT gateway handles this — outbound traffic goes through it, but nothing unsolicited comes back in. AWS charges $0.045/hour per NAT gateway plus $0.045/GB of data processed. That is $32/month just for the gateway to exist, before a single byte passes through it. For what amounts to a managed iptables MASQUERADE rule on a hidden EC2 instance, that pricing is absurd. A t3.nano running NAT in software costs $3.80/month. AWS knows you need this for any private subnet architecture and prices accordingly.

  1. VPC → NAT gateways → Create NAT gateway
  2. Name: nat-gw-1a
  3. Subnet: public-subnet-1a (NAT gateway goes in a PUBLIC subnet)
  4. Elastic IP: Click "Allocate Elastic IP" (costs money when not attached to anything)
  5. Create

For production, create a second NAT gateway in public-subnet-1b. For a staging or dev environment, one is enough.

🔒 The fix that actually mattered:

The NACL on the data subnets (private-data-1a, private-data-1b): inbound rule 100 ALLOW TCP 5432 from 10.0.10.0/23 (the app-tier CIDR range only), inbound rule * DENY all traffic. Outbound rule 100 ALLOW TCP 1024-65535 to 10.0.10.0/23 (ephemeral ports back to app tier), outbound rule * DENY all. That is the entire ruleset. The database subnets cannot talk to anything except the app servers on the Postgres port, and the app servers cannot be reached from the public subnets. The attacker's SYN scans would have hit DENY at the NACL before the security group even evaluated.

Private Route Table

  1. Create route table: private-rt
  2. Add route: 0.0.0.0/0 → Target: NAT Gateway → Select your NAT gateway
  3. Associate with all 4 private subnets

Private instances can now pull updates and call external APIs. Inbound connections from the internet still cannot reach them.

💡 No internet from private subnet?

Instance can't reach the internet from a private subnet? Walk the path: does the route table have a 0.0.0.0/0 route to the NAT gateway? Is the NAT gateway in a public subnet? Does that subnet's route table have a route to the internet gateway? Every link in the chain has to be there.

Step 6: Application Load Balancer

The ALB goes in the public subnets. It forwards requests to your app servers in the private subnets.

  1. Go to EC2 → Load Balancers
  2. Create load balancer → Choose Application Load Balancer
  3. Name: production-alb
  4. Scheme: Internet-facing
  5. IP address type: IPv4

Network Mapping

Security Group

Create a new security group for the ALB:

Target Group

  1. Create a target group
  2. Target type: Instances
  3. Name: app-targets
  4. Protocol: HTTP, Port: 80
  5. VPC: Your VPC
  6. Health check path: /health (or / if you don't have a health endpoint)

Leave the target group empty for now. Instances get registered after launch.

Listener

Set the default listener action to forward to your target group. Create the ALB.

Step 7: Launch App Servers

Create EC2 instances in the private app subnets:

  1. Launch Instance
  2. Select your VPC
  3. Subnet: private-app-1a
  4. NO public IP (it's a private subnet)

Security Group for App Servers

App servers accept traffic ONLY from the load balancer, not from the internet directly.

Register with Target Group

  1. EC2 → Target Groups → app-targets
  2. Register targets
  3. Select your instances
  4. Include as pending

Step 8: Bastion Host (Jump Box)

A bastion host is a small instance in the public subnet. You SSH to it first, then jump to private instances from there. Nothing else should have a public IP.

  1. Launch a t2.micro in public-subnet-1a
  2. Security group: SSH (22) from your IP only
  3. This is the only way into your private network

SSH config for easy access:

~/.SSH/config
# Bastion
Host bastion
 HostName 54.xxx.xxx.xxx # Bastion public IP
 User ec2-user
 IdentityFile ~/.ssh/my-key.pem

# App server (through bastion)
Host app-server
 HostName 10.0.10.xxx # Private IP
 User ec2-user
 IdentityFile ~/.ssh/my-key.pem
 ProxyJump bastion

Now ssh app-server automatically jumps through the bastion.

Step 9: Database in Private Data Subnet

RDS configuration for the data tier:

No public access, no internet route, security group locked to app-sg. Three layers between an attacker and your data.

Testing the Architecture

  1. Get the ALB DNS name (something.elb.amazonaws.com)
  2. Open it in a browser — your app loads
  3. Try the app server's private IP directly — connection times out
  4. Try the RDS endpoint from your laptop — connection times out

Traffic enters through the ALB and nowhere else. If steps 3 and 4 succeed, your architecture is broken.

Costs

Monthly cost for this setup in us-east-1:

For dev environments, skip the NAT gateway entirely and use VPC endpoints for S3 and ECR. Run a single AZ. Save yourself $64/month on something nobody will notice.

Quick Checklist

The Final Topology

Your VPC should look like this: internet traffic hits the internet gateway, which routes to the ALB in the public subnets (10.0.1.0/24 and 10.0.2.0/24). The ALB forwards to app servers in the private app subnets (10.0.10.0/24 and 10.0.11.0/24). App servers connect to RDS in the isolated data subnets (10.0.20.0/24 and 10.0.21.0/24). The NAT gateway in the public subnet gives the app tier outbound internet access. Nothing else touches RDS directly. Not the bastion, not the NAT gateway, not the ALB. The data subnets have no route to the internet gateway and NACLs that deny everything except TCP 5432 from the app-tier CIDR. If an attacker compromises the ALB or the bastion, they still cannot reach the database without first pivoting through an app server — and the app server's security group only accepts traffic from the ALB on port 80. Every hop requires a separate compromise.

💬 Comments