Project: Three-Tier VPC Architecture
A three-tier VPC is the standard way to lay out a serious application on AWS. You split your network into three layers: a public tier for load balancers, a private tier for your application servers, and an isolated tier for your database. Each tier is more locked down than the one in front of it, so even if a hacker reaches the front door they still cannot touch your data. This project walks you through building one by hand so you understand every piece, then shows you how to tear it all down.
A VPC (Virtual Private Cloud) is your own private, isolated network inside AWS. Everything you build lives in subnets, which are slices of the VPC’s address range tied to a single Availability Zone (AZ — a physically separate data center). We will span two AZs for high availability.
Plan the address space
Before you click anything, decide on your CIDR (Classless Inter-Domain Routing) block — the range of private IP addresses your VPC owns. A /16 gives you 65,536 addresses, which is plenty of room to grow. We will carve it into six /24 subnets (256 addresses each), two per tier.
| Subnet | Tier | AZ | CIDR | Internet access |
|---|---|---|---|---|
| public-a | Public | us-east-1a | 10.0.0.0/24 | Yes (via IGW) |
| public-b | Public | us-east-1b | 10.0.1.0/24 | Yes (via IGW) |
| private-a | Private (app) | us-east-1a | 10.0.10.0/24 | Outbound only (NAT) |
| private-b | Private (app) | us-east-1b | 10.0.11.0/24 | Outbound only (NAT) |
| isolated-a | Isolated (db) | us-east-1a | 10.0.20.0/24 | None |
| isolated-b | Isolated (db) | us-east-1b | 10.0.21.0/24 | None |
Plan your CIDR once. You cannot shrink a VPC’s primary CIDR after creation. Leave gaps between tiers (10, 20…) so you can add subnets later without renumbering.
Create the VPC and subnets
When to use this: any application that needs its own isolated network with separation between web, app, and data layers — which is almost every production workload.
Console
- Go to the VPC console and choose Create VPC.
- Select VPC only, name it
three-tier-vpc, and set the IPv4 CIDR to10.0.0.0/16. Create it. - Open Subnets > Create subnet, pick
three-tier-vpc, and add all six subnets from the table above, choosing the right AZ and CIDR for each.
CLI
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
--tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=three-tier-vpc}]' \
--query 'Vpc.VpcId' --output text)
aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.0.0/24 \
--availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=public-a}]'
Output:
{
"Subnet": {
"SubnetId": "subnet-0a1b2c3d",
"VpcId": "vpc-0a1b2c3d",
"CidrBlock": "10.0.0.0/24",
"AvailabilityZone": "us-east-1a",
"State": "available"
}
}
Repeat create-subnet for the other five CIDRs and AZs.
Add the internet gateway
An internet gateway (IGW — the device that connects your VPC to the public internet) is what lets public subnets reach the outside world.
Console
- In Internet gateways, choose Create internet gateway, name it
three-tier-igw. - Select it, then Actions > Attach to VPC, and pick
three-tier-vpc.
CLI
IGW_ID=$(aws ec2 create-internet-gateway \
--query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
NAT gateways — one per AZ
A NAT gateway (Network Address Translation — lets private instances make outbound connections but blocks inbound ones) lives in a public subnet and needs an Elastic IP (a permanent public IP address). Your private app servers route through it to download patches or call external APIs, while staying unreachable from the internet.
When to use one per AZ (and when not): in production, put a NAT gateway in each AZ’s public subnet. If you use a single shared NAT, every private subnet’s outbound traffic crosses AZs — that incurs cross-AZ data charges and creates a single point of failure if that AZ goes down. For a throwaway dev environment, a single NAT is fine to save money.
Console
- In Elastic IPs, Allocate Elastic IP address (do this twice).
- In NAT gateways > Create NAT gateway, choose subnet
public-a, attach one EIP. Repeat forpublic-b.
CLI
EIP_A=$(aws ec2 allocate-address --query 'AllocationId' --output text)
NAT_A=$(aws ec2 create-nat-gateway --subnet-id subnet-0a1b2c3d \
--allocation-id $EIP_A --query 'NatGateway.NatGatewayId' --output text)
Output:
{
"NatGateway": {
"NatGatewayId": "nat-0a1b2c3d",
"SubnetId": "subnet-0a1b2c3d",
"NatGatewayAddresses": [{"AllocationId": "eipalloc-0a1b2c3d"}],
"State": "pending"
}
}
Cost note: each NAT gateway costs about $0.045/hour (~$32/month) plus $0.045/GB processed. Two NAT gateways is roughly $65/month before data. This is usually the most expensive part of a small VPC — delete them when you are done experimenting.
Route tables — associate every subnet explicitly
A route table is a set of rules deciding where traffic goes. This is the step everyone gets wrong.
The #1 gotcha: a subnet you do not explicitly associate with a route table silently falls back to the VPC’s main route table. That can accidentally expose a database subnet or break connectivity. Always associate each subnet with the table you intend.
Create three kinds of route tables:
- Public RT: default route
0.0.0.0/0to the IGW. Associate both public subnets. - Private RT (per AZ): default route
0.0.0.0/0to that AZ’s NAT gateway. Associate the matching private subnet. - Isolated RT: only the local VPC route, no internet route at all. Associate both isolated subnets.
Console
- In Route tables > Create route table, name it
public-rt, pick the VPC. - Open its Routes tab > Edit routes, add
0.0.0.0/0> target the IGW. - On the Subnet associations tab, associate
public-aandpublic-b. - Repeat for
private-rt-a(targetnat-a, associateprivate-a),private-rt-b, andisolated-rt(no extra route, associate both isolated subnets).
CLI
PUB_RT=$(aws ec2 create-route-table --vpc-id $VPC_ID \
--query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $PUB_RT \
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
aws ec2 associate-route-table --route-table-id $PUB_RT --subnet-id subnet-0a1b2c3d
# Private RT points at the NAT gateway, not the IGW
PRIV_RT_A=$(aws ec2 create-route-table --vpc-id $VPC_ID \
--query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $PRIV_RT_A \
--destination-cidr-block 0.0.0.0/0 --nat-gateway-id $NAT_A
aws ec2 associate-route-table --route-table-id $PRIV_RT_A --subnet-id subnet-0b2c3d4e
Layer security: security groups and NACLs
You have two firewalls, and using both is defense in depth.
| Control | Scope | State | Rules | Best for |
|---|---|---|---|---|
| Security group | Per instance/ENI | Stateful (return traffic auto-allowed) | Allow only | Day-to-day tier-to-tier rules |
| Network ACL (NACL) | Per subnet | Stateless (must allow both directions) | Allow and deny | Broad subnet-level guardrails |
Chain the security groups so each tier only accepts traffic from the tier in front of it:
# DB SG: allow PostgreSQL only from the app SG, nothing else
aws ec2 authorize-security-group-ingress --group-id sg-0a1b2c3d \
--protocol tcp --port 5432 --source-group sg-0b2c3d4e
The web tier allows 443 from the internet, the app tier allows its port only from the web SG, and the database tier allows 5432 only from the app SG. Because the isolated subnets have no internet route, the database has no path to the internet at all — exactly what you want.
Best practices
- Span at least two AZs for every tier so one data center failure does not take you down.
- Put one NAT gateway per AZ to avoid cross-AZ data charges and a single point of failure.
- Explicitly associate every subnet with its intended route table — never rely on the main table.
- Keep the database tier in isolated subnets with no route to
0.0.0.0/0, inbound or outbound. - Use security groups for tier-to-tier rules and reference SGs by ID (not IP ranges) so they auto-update.
- Tag everything with a project name so you can find and bulk-delete resources during cleanup.
Cleanup
NAT gateways and Elastic IPs cost money while idle, so delete in dependency order: NAT gateways first, then release EIPs, detach and delete the IGW, delete subnets and route tables, and finally the VPC.
aws ec2 delete-nat-gateway --nat-gateway-id $NAT_A
aws ec2 release-address --allocation-id $EIP_A
aws ec2 detach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
aws ec2 delete-internet-gateway --internet-gateway-id $IGW_ID
aws ec2 delete-vpc --vpc-id $VPC_ID
Tip: NAT gateway deletion takes a minute or two. Wait for its state to reach
deletedbefore releasing the EIP, or the release fails because the address is still in use.