Filter concepts by levelShowing all levels.

AWS · Section 8

Subnets, Routing, and Gateways

Level
intermediate
Read
28 min
Concepts
5

Once a VPC exists, every subnet inside it needs a size that leaves room to grow and a route table that decides where its traffic actually goes. This section covers subnet CIDR planning (and the 5 addresses AWS always reserves), how route-table associations and the default route decide egress, why a NAT gateway is structurally outbound-only, how VPC endpoints keep AWS-service traffic off the public internet and off the NAT bill entirely, and the cost and architecture tradeoffs of overusing NAT gateways.

What is true here

  1. Every IPv4 subnet reserves exactly 5 addresses (4 at the start, 1 at the end) — usable count is 2^(32−prefix) − 5.
  2. A subnet's route table (local route automatic, default route configurable) decides where its outbound traffic actually goes.
  3. A NAT gateway allows outbound-initiated connections and their return traffic only — there is no path for an unsolicited inbound connection.
  4. Gateway endpoints (S3, DynamoDB — free) and interface endpoints (most other services — billed) keep traffic off the NAT gateway and off the public internet.
  5. NAT gateways bill hourly and per-GB — one per Availability Zone, not per subnet, is the standard resilient and cost-sane pattern.

What you will be able to do

  • Compute a subnet's usable IP count from its CIDR prefix, accounting for the 5 reserved addresses
  • Trace a subnet's route table to explain exactly where its outbound traffic goes
  • Explain why a NAT gateway can never be reached from an unsolicited inbound connection
  • Choose between a gateway endpoint, an interface endpoint, and NAT for a given service's traffic
  • Justify a one-NAT-gateway-per-AZ design over one per subnet or one shared across all AZs
A private subnet's outbound options
outboundtrafficAWS service traffic,if availableeverythingelse

Private subnet

sized with usable-IP headroom

Route table decides

VPC endpoint

S3/DynamoDB or PrivateLink — stays private

NAT gateway

outbound-only, billed hourly + per-GB

  • Private subnet — sized with usable-IP headroom
    • leads to Route table decides (outbound traffic)
  • Route table decides
    • leads to VPC endpoint (AWS service traffic, if available)
    • leads to NAT gateway (everything else)
  • VPC endpoint — S3/DynamoDB or PrivateLink — stays private
  • NAT gateway — outbound-only, billed hourly + per-GB

Subnets, Routing, and Gateways

Sizing a subnet correctly, how its route table decides egress, NAT's one-way nature, VPC endpoints, and NAT cost/architecture tradeoffs.

Subnet CIDR planning and IP allocation

coreintermediate

A subnet's CIDR size decides how many IP addresses it holds — but AWS reserves 5 addresses in every subnet, so a /28 (16 addresses) really only has 11 usable, not 16.

Think of it as

An apartment building with a fixed number of units, where the landlord permanently keeps 5 units for utilities and management no matter how large the building is — a bigger building just means more units left over for tenants.

text
usable IPs = 2^(32 - prefix) - 5

What we're doing: Compute how many usable addresses a planned subnet actually has, accounting for the 5 AWS reserves.

usable_ips.pypython
def usable_ips(prefix_len):
    total = 2 ** (32 - prefix_len)
    return total - 5  # AWS reserves 5 addresses in every subnet

print(usable_ips(24))  # 251
print(usable_ips(28))  # 11
1
The -5 applies uniformly regardless of subnet size — it is a fixed AWS reservation, not a percentage.
3
A /28 with only 16 total addresses loses nearly a third of its space to reserved addresses — the smaller the subnet, the bigger that proportion.

Why this works: Planning a subnet by total CIDR size alone (rather than usable IPs) is the single most common way teams run out of address space earlier than expected — 5 reserved addresses is a bigger bite out of a small subnet than a large one.

Sizing a subnet for exactly the current instance count

Wrong

text
# "We have 10 instances today, so a /28 (11 usable) fits exactly."

Better

text
# Size for headroom — Auto Scaling, rolling deployments, and future
# growth all consume addresses beyond the current running count

What you see: An Auto Scaling Group fails to launch a replacement instance during a rolling deployment because the subnet has run out of free IP addresses.

Why: A subnet sized to exactly today's count leaves no room for rolling deployments (which briefly run old and new instances together), Auto Scaling bursts, or future growth — all of which need spare addresses, not just enough for a static snapshot of today.

A /28 subnet — 16 addresses, only 11 usable

def usable_ips(prefix_len): total = 2 ** (32 - prefix_len) return total - 5 # AWS reserves 5 addresses in every subnet print(usable_ips(28)) # 11

2 ** (32 - prefix_len)

total addresses — /28 → 16 total addresses in the block

total - 5

usable = total − 5 — network, router, DNS, future-use, and broadcast are always reserved

  • Whole: def usable_ips(prefix_len): total = 2 ** (32 - prefix_len) return total - 5 # AWS reserves 5 addresses in every subnet print(usable_ips(28)) # 11
  • 2 ** (32 - prefix_len) — total addresses: /28 → 16 total addresses in the block
  • total - 5 — usable = total − 5: network, router, DNS, future-use, and broadcast are always reserved

Common subnet sizes and their usable IP count (5 always reserved)

Common subnet sizes and their usable IP count (5 always reserved)
CIDRTotal addressesUsable addresses
/24256251
/25128123
/266459
/273227
/281611 — smallest AWS allows

Together

bash
aws ec2 create-subnet --vpc-id vpc-0abc123 --cidr-block 10.0.1.0/26 --availability-zone eu-west-1a

Remember: Every AWS subnet reserves exactly 5 addresses — usable count is 2^(32 − prefix) − 5, not the raw block size, and smaller subnets lose a proportionally bigger share to that reservation.

See also: non overlapping cidr design · route tables and egress paths

Route-table associations and egress paths

coreintermediate

Every subnet is associated with exactly one route table (the VPC's main table, unless explicitly changed), and that table's routes — most importantly the default route — decide where traffic leaving the subnet actually goes.

Think of it as

A dispatcher at a warehouse who checks a printed list of destinations for every outgoing package — the "local" line handles anything staying in the building, and a catch-all last line says where everything else goes.

text
Route table:
  10.0.0.0/16 → local          (automatic, cannot be removed)
  0.0.0.0/0   → igw-xxx | nat-xxx | (absent)

What we're doing: See a full route table with both the automatic local route and an added default route.

describe-route-tables-output.jsonjson
{
  "Routes": [
    { "DestinationCidrBlock": "10.0.0.0/16", "GatewayId": "local" },
    { "DestinationCidrBlock": "0.0.0.0/0", "NatGatewayId": "nat-0abc123" }
  ],
  "Associations": [
    { "SubnetId": "subnet-0priv1" }
  ]
}
4
The "local" route was never explicitly created — every route table gets it automatically and it cannot be deleted.
8
This route table is explicitly associated with one specific subnet, overriding whatever the VPC's main route table says.

Why this works: The local route guarantees resources within the VPC can always reach each other regardless of what the default route points at — losing internet access never means losing intra-VPC connectivity.

Deleting a NAT gateway without checking who depends on it

Wrong

text
# "This NAT gateway looks unused, deleting it to save cost."

Better

text
# Check which route tables have a default route pointing at this
# NAT gateway before deleting — every associated subnet loses internet egress

What you see: Every private-subnet instance depending on that NAT gateway loses outbound internet access simultaneously, with error messages that point at package registries or API timeouts rather than the actual networking cause.

Why: A route table's default route pointing at a deleted NAT gateway does not fail loudly — the route becomes a black hole, and traffic silently stops rather than erroring in an obviously networking-related way.

A private subnet's outbound path
outboundtrafficdefaultrouteNAT's own subnetroutes here

Private subnet

Route table

0.0.0.0/0 → NAT gateway

NAT gateway

lives in a public subnet

Internet gateway

  • Private subnet
    • leads to Route table (outbound traffic)
  • Route table — 0.0.0.0/0 → NAT gateway
    • leads to NAT gateway (default route)
  • NAT gateway — lives in a public subnet
    • leads to Internet gateway (NAT's own subnet routes here)
  • Internet gateway

What a subnet's default route points at

What a subnet's default route points at
Default route targetEffect
Internet gateway (igw-...)Public — inbound and outbound internet
NAT gateway (nat-...)Private — outbound-only internet, no inbound
No default route presentFully isolated — no internet path either direction

Together

bash
aws ec2 create-route --route-table-id rtb-0priv1 \
  --destination-cidr-block 0.0.0.0/0 --nat-gateway-id nat-0abc123

Remember: The local route (auto-present, unremovable) guarantees intra-VPC connectivity; the default route (0.0.0.0/0) decides egress — internet gateway makes it public, NAT gateway keeps it private but outbound-capable.

See also: subnet cidr planning · nat gateway cost tradeoffs · public vs private subnets

Inbound reachability vs outbound-only access

standardintermediate

A private subnet with a NAT gateway can reach the internet (outbound) — download a package, call an external API — but the internet can never initiate a connection back in. That asymmetry is the entire reason private subnets exist.

Think of it as

A one-way mirror in an interview room: the person inside can see out and act on what they see, but nobody outside can see or reach in.

text
Private subnet, outbound via NAT:
  app → api.example.com     ✓ initiated from inside
  internet → app            ✗ never allowed in, regardless of port

What we're doing: See why a private instance can call out to an API but can never be reached directly, even on an open port.

traffic-directions.txttext
# Allowed — outbound, initiated from the private subnet
app-server (private) --request--> api.example.com:443
app-server (private) <--response-- api.example.com:443   # return traffic, same connection

# Blocked — inbound, initiated from outside
attacker --connect attempt--> app-server (private):443   # no path exists, NAT is outbound-only
2
The private-subnet instance is the one that opened this connection — NAT allows the response to come back on the same connection.
5
This is a brand-new, externally initiated connection attempt — there is no NAT mapping for it, so it has nowhere to go, security group settings aside.

Why this works: NAT's asymmetry is a structural property of the gateway itself, not a rule that can be misconfigured open — even a maximally permissive security group on the instance cannot make it reachable from the internet through a NAT gateway.

Trying to expose a private-subnet instance directly to the internet by opening its security group

Wrong

text
# "I opened port 443 in the security group but it's still unreachable from outside."

Better

text
# A private instance needs to sit behind something with an inbound path
# (a load balancer in a public subnet) — the security group was never the blocker

What you see: Every security group and NACL rule checks out as permissive, yet the instance remains completely unreachable from outside the VPC.

Why: Security groups and NACLs control what is allowed once a connection can physically reach the instance — a private subnet's routing (via NAT, not an internet gateway) means no inbound connection attempt ever arrives to be evaluated against those rules in the first place.

Remember: A NAT gateway is structurally outbound-only — it allows a private subnet to call out and receive the response, but there is no path for the internet to initiate a connection in, regardless of security group settings.

See also: route tables and egress paths · security groups as stateful firewalls

VPC endpoints: gateway vs interface

coreintermediate

A VPC endpoint lets a private subnet reach an AWS service (like S3 or DynamoDB) without going through a NAT gateway or the public internet at all — the traffic stays entirely inside AWS's network.

Think of it as

A direct internal hallway between two departments in the same office building, instead of walking outside, down the street, and back in through the other department's front door.

text
Without an endpoint: private subnet → NAT gateway → internet gateway → S3 (public endpoint)
With a gateway endpoint: private subnet → S3, entirely inside AWS's network

What we're doing: See how adding a gateway endpoint changes the route table, without touching the application code at all.

route-table-with-endpoint.jsonjson
{
  "Routes": [
    { "DestinationCidrBlock": "10.0.0.0/16", "GatewayId": "local" },
    { "DestinationPrefixListId": "pl-0abc-s3", "GatewayId": "vpce-0def456" },
    { "DestinationCidrBlock": "0.0.0.0/0", "NatGatewayId": "nat-0abc123" }
  ]
}
4
Traffic to S3's IP ranges (the prefix list) now routes to the VPC endpoint instead of falling through to the NAT gateway default route.

Why this works: The application's S3 client code does not change at all — it still calls the same S3 API. The route table silently redirects that traffic onto a private path instead of out through the NAT gateway, which also stops that traffic from being billed as NAT data processing.

Routing all NAT-gateway S3 traffic through NAT indefinitely without adding a gateway endpoint

Wrong

text
# Every S3 request from a private subnet flows through the NAT gateway,
# incurring NAT data processing charges, indefinitely

Better

text
# Add a gateway endpoint for S3 — free, and it automatically takes S3
# traffic off the NAT gateway's data path

What you see: A cost review finds a large, steady NAT gateway data-processing charge that turns out to be almost entirely S3 traffic that never needed to leave AWS's network in the first place.

Why: A gateway endpoint for S3 costs nothing extra to add and takes matching traffic off the NAT gateway's billed data path automatically — there is close to no downside to adding one whenever a private subnet talks to S3 or DynamoDB regularly.

Gateway endpoint vs interface endpoint

Gateway endpoint

  • +S3, DynamoDB only
  • +A route-table target (prefix list)
  • +No additional charge

Interface endpoint

  • Most other AWS services
  • An ENI with a private IP + PrivateLink
  • Billed hourly + data processing
  • Gateway endpoint
    • S3, DynamoDB only
    • A route-table target (prefix list)
    • No additional charge
  • Interface endpoint
    • Most other AWS services
    • An ENI with a private IP + PrivateLink
    • Billed hourly + data processing

Gateway endpoint vs interface endpoint

Gateway endpoint vs interface endpoint
PropertyGateway endpointInterface endpoint
ServicesS3, DynamoDB onlyMost other AWS services
ImplementationRoute-table target (prefix list)ENI with a private IP, PrivateLink
CostNo additional chargeHourly + data processing charges
Reachable fromSame VPC onlyVPC, plus on-prem via Direct Connect/VPN

Together

bash
aws ec2 create-vpc-endpoint --vpc-id vpc-0abc123 --service-name com.amazonaws.eu-west-1.s3 \
  --route-table-ids rtb-0priv1 --vpc-endpoint-type Gateway

Remember: Gateway endpoints (S3, DynamoDB — free, route-table based) and interface endpoints (most other services — billed, DNS + ENI based) both keep traffic off the NAT gateway and off the public internet entirely.

See also: route tables and egress paths · nat gateway cost tradeoffs

NAT gateway cost and architecture tradeoffs

standardintermediate

A NAT gateway bills by the hour it exists AND by every gigabyte of data it processes — one per AZ for high availability adds up fast, and traffic that could have used a free VPC endpoint instead pays the data-processing rate for no reason.

Think of it as

A toll booth that charges both a standing rent for existing and a per-car fee for every car that passes — cheap to avoid entirely for trips that have a free side road (a VPC endpoint) available instead.

text
NAT gateway cost = (hourly rate × hours running) + (per-GB rate × data processed)

What we're doing: See the two independent NAT gateway cost drivers, and where a VPC endpoint removes one of them entirely.

cost-comparison.txttext
# Without an S3 gateway endpoint
private-subnet → NAT gateway (hourly + per-GB) → internet gateway → S3

# With an S3 gateway endpoint
private-subnet → S3 directly (no NAT hourly or per-GB charge for this traffic)
2
Every byte of S3 traffic here is billed at the NAT gateway's per-GB rate, in addition to the gateway's standing hourly charge.
5
The same S3 traffic now bypasses the NAT gateway entirely — no per-GB NAT charge, and the gateway endpoint itself is free.

Why this works: The NAT gateway's per-GB charge applies to all traffic that flows through it, including traffic to AWS services that have a free, direct alternative — the architecture choice (adding an endpoint) directly changes the cost, not just efficiency.

Deploying one NAT gateway per subnet instead of per Availability Zone

Wrong

text
# 6 private subnets across 3 AZs → 6 NAT gateways, one per subnet

Better

text
# 6 private subnets across 3 AZs → 3 NAT gateways, one per AZ,
# shared by every private subnet in that AZ

What you see: The NAT gateway hourly bill is double what a standard highly-available design would cost, for no additional resilience.

Why: Multiple private subnets in the same AZ can share one NAT gateway — one per subnet multiplies the standing hourly charge without adding any availability benefit, since all the subnets sharing an AZ already fail together if that AZ has an outage.

Remember: NAT gateways bill hourly AND per-GB — one per AZ (not per subnet) is the standard resilient pattern, and a gateway endpoint removes S3/DynamoDB traffic from the NAT bill entirely.

See also: vpc endpoints · route tables and egress paths

Advertisement