AWS Networking & Identity

Subnets, Route Tables & Gateways

18 min Lesson 2 of 28

Subnets, Route Tables & Gateways

A VPC by itself is an empty address space. Subnets, route tables, and gateways are the three mechanisms that carve that space into functional network segments and control how traffic flows in, out, and between them. Getting this topology right is the single most impactful architectural decision in an AWS account — mistakes here cause security incidents, unexpected data-transfer bills, and multi-hour outages.

Subnets: Carving the VPC

A subnet is a contiguous block of IP addresses within your VPC CIDR, confined to a single Availability Zone. Every EC2 instance, RDS instance, Lambda VPC attachment, and ECS task runs inside a subnet. The subnet is where AZ-level fault isolation actually lives.

The canonical production layout uses three tiers across at least two AZs:

  • Public subnets — hosts that must be directly reachable from the internet (load balancers, NAT gateways, bastion hosts). Each public subnet has auto-assign public IPv4 enabled and is associated with a route table that has a default route to an Internet Gateway.
  • Private subnets — application tier (ECS tasks, EC2 app servers, Lambda). No public IP. Outbound internet access goes through a NAT Gateway sitting in the public subnet of the same AZ.
  • Isolated (data) subnets — RDS, ElastiCache, OpenSearch. No route to the internet at all — not even NAT. Traffic is VPC-internal or flows through VPC Endpoints only.
AZ symmetry matters. Mirror every subnet tier in every AZ you use. If you put a NAT Gateway only in us-east-1a and that AZ has a partial outage, all private instances in us-east-1b that route to it lose internet. Always one NAT per AZ — the data-transfer cost is negligible compared to the blast radius.

CIDR Sizing Rules

AWS reserves 5 addresses per subnet (network, VPC router, DNS, future, broadcast). A /28 gives only 11 usable IPs — fine for a subnet holding one NAT Gateway, too small for anything else. A /24 gives 251 usable. Most teams allocate /20 blocks for public and private, /24 for isolated. Never resize a subnet after launch — plan for 3× your expected peak and use non-overlapping RFC 1918 ranges if you intend to peer or connect to on-premises networks.

Internet Gateways (IGW)

An Internet Gateway is a horizontally scaled, fully managed VPC component that enables bidirectional IPv4/IPv6 traffic between your VPC and the public internet. There is exactly one IGW per VPC. Attaching it costs nothing; data-transfer pricing applies to outbound traffic. An IGW does two things:

  1. Acts as the target for the default route (0.0.0.0/0 → igw-xxxxxxxx) in a public subnet's route table.
  2. Performs 1:1 NAT between an instance's private IP and its Elastic IP (or auto-assigned public IP) for instances that have one.
Public subnet ≠ public instance. A subnet is "public" only because its route table points to an IGW. An instance in that subnet is only reachable from the internet if it also has a public IP or Elastic IP. You can have instances in a public subnet with no public IP — they can initiate outbound internet traffic but cannot be reached inbound. This is a useful pattern for NAT Gateways themselves.

NAT Gateways

Private instances often need to reach the internet for package updates, pulling container images, or calling external APIs — but you do not want them exposed inbound. A NAT Gateway (NGW) solves this. It lives in a public subnet, holds an Elastic IP, and performs port-address translation for outbound connections from private instances. AWS manages the gateway entirely — no patching, no instance sizing, automatic scaling to 100 Gbps.

Critical operational properties:

  • NAT Gateways are AZ-scoped. Route each private subnet to the NAT Gateway in the same AZ to avoid cross-AZ data transfer charges and AZ-dependency failures.
  • They are one-way: private instances can initiate connections out; nothing can initiate connections in through NAT.
  • They do not support inbound connections or port-forwarding.
  • Cost model: $0.045/hr (us-east-1) + $0.045/GB processed. Three NAT Gateways (one per AZ) in a busy environment can run $200–$400/month — keep that in the cost model from day one.
Private subnet to private subnet across AZs still goes through NAT if you misconfigure routes. A common mistake is routing all private subnets to a single NAT Gateway. When that AZ degrades, all outbound internet from every private subnet drops. The fix: one route table per AZ-tier, each pointing to its own NGW.

Route Tables

A route table is an ordered list of destination-prefix → target rules. Every subnet must be associated with exactly one route table. If you do not explicitly associate one, the subnet uses the VPC's main route table — which is fine for isolated subnets, risky for everything else because changes to the main table affect every implicitly associated subnet.

A minimal three-tier setup needs at minimum:

  • Public RT10.0.0.0/16 → local (auto-created) + 0.0.0.0/0 → igw-xxx
  • Private RT (per AZ)10.0.0.0/16 → local + 0.0.0.0/0 → nat-xxx-in-same-AZ
  • Isolated RT10.0.0.0/16 → local only

Routes are matched longest-prefix first. The local route (your VPC CIDR) is always implicitly present and cannot be deleted — it ensures all subnets can reach each other by default.

Infrastructure as Code: Terraform Example

In production, every VPC component is defined in Terraform. The following excerpt creates one public subnet, one private subnet, an IGW, a NAT Gateway, and their route tables in a single AZ. A real module loops this across multiple AZs.

# Internet Gateway — one per VPC resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id tags = { Name = "main-igw" } } # Public subnet (us-east-1a) resource "aws_subnet" "public_1a" { vpc_id = aws_vpc.main.id cidr_block = "10.0.0.0/20" availability_zone = "us-east-1a" map_public_ip_on_launch = true tags = { Name = "public-1a", Tier = "public" } } # Private subnet (us-east-1a) resource "aws_subnet" "private_1a" { vpc_id = aws_vpc.main.id cidr_block = "10.0.16.0/20" availability_zone = "us-east-1a" tags = { Name = "private-1a", Tier = "private" } } # Elastic IP for NAT Gateway resource "aws_eip" "nat_1a" { domain = "vpc" } # NAT Gateway — lives in the PUBLIC subnet resource "aws_nat_gateway" "nat_1a" { allocation_id = aws_eip.nat_1a.id subnet_id = aws_subnet.public_1a.id tags = { Name = "nat-1a" } depends_on = [aws_internet_gateway.main] } # Public route table resource "aws_route_table" "public" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.main.id } tags = { Name = "rt-public" } } resource "aws_route_table_association" "public_1a" { subnet_id = aws_subnet.public_1a.id route_table_id = aws_route_table.public.id } # Private route table — routes outbound through NAT in same AZ resource "aws_route_table" "private_1a" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.nat_1a.id } tags = { Name = "rt-private-1a" } } resource "aws_route_table_association" "private_1a" { subnet_id = aws_subnet.private_1a.id route_table_id = aws_route_table.private_1a.id }

Verifying the Layout with AWS CLI

After applying, verify route tables and their associations before deploying workloads:

# Describe all route tables in the VPC aws ec2 describe-route-tables \ --filters "Name=vpc-id,Values=vpc-0abc1234" \ --query "RouteTables[*].{ID:RouteTableId,Routes:Routes[*].{Dest:DestinationCidrBlock,Target:GatewayId||NatGatewayId}}" \ --output table # Confirm NAT Gateway is in Available state aws ec2 describe-nat-gateways \ --filter "Name=vpc-id,Values=vpc-0abc1234" \ --query "NatGateways[*].{ID:NatGatewayId,State:State,AZ:SubnetId}" \ --output table # Quick connectivity test from a private instance (SSM Session Manager) aws ssm start-session --target i-0privateinstance # inside: curl -s https://checkip.amazonaws.com # should return the EIP of the NAT Gateway — not the instance private IP

VPC Architecture Diagram

Three-tier VPC with public, private, and isolated subnets across two AZs VPC 10.0.0.0/16 Internet Internet Gateway AZ: us-east-1a AZ: us-east-1b Public Subnet 10.0.0.0/20 ALB / Bastion NAT GW (EIP) Public Subnet 10.0.32.0/20 ALB / Bastion NAT GW (EIP) Private Subnet 10.0.16.0/20 App Servers / ECS Private Subnet 10.0.48.0/20 App Servers / ECS Isolated Subnet 10.0.64.0/24 RDS / ElastiCache Isolated Subnet 10.0.65.0/24 RDS / ElastiCache VPC local only RT: 0/0 → IGW RT: 0/0 → NAT (same AZ) RT: local only
Three-tier VPC layout: public subnets (IGW-routed), private subnets (NAT-routed), and isolated subnets (no internet route), mirrored across two AZs.

Production Failure Modes to Know

Missing depends_on for NAT Gateway. In Terraform, if the NAT Gateway resource is created before the IGW is attached to the VPC, AWS returns an error because NAT requires an IGW to exist first. Always set depends_on = [aws_internet_gateway.main] on the NAT Gateway resource.

Route table not associated. A freshly created route table has no subnet associations. Terraform's aws_route_table_association resource (or aws_subnet.route_table_id in CDK) performs the binding. Without it, the subnet silently falls back to the main route table.

NAT Gateway in wrong subnet. NAT Gateways must reside in a public subnet. Placing one in a private subnet creates a circular dependency — the private subnet's route points to NAT, NAT itself has no internet path, so all outbound traffic silently drops.

Tag everything with Tier and AZ. Resources tagged Tier=public, Tier=private, Tier=isolated and AZ=us-east-1a enable cost explorer breakdowns by tier, Security Hub findings by tier, and fast manual auditing. Make this a required tag in your AWS Organizations SCP from day one.