Notes · Learn AWS · CHAPTER 3

Networking (VPC)

Networking is where Azure habits mislead the most. The vocabulary looks identical - VNet, subnet, NSG, peering - but the defaults, the stateful/stateless split, and the cost model are all different. This chapter rewires those reflexes before they cost you a NAT-gateway-sized bill.

An Azure VNet feels like a polite suburban street: NSGs are stateful, the default subnet is private, and most traffic flows the way you expect. An AWS VPC feels like the same street but with two sets of gate guards (security groups and NACLs) at every house, no default outbound route, and a NAT toll-booth that charges per byte. Same goal, different machinery. We'll build the model piece by piece, then provision the VPC that Project Compass will live in.

In this chapter
  1. The cheat table
  2. VPC anatomy: CIDR, subnets, AZs
  3. Route tables, IGW, NAT GW
  4. Security Groups vs NACLs
  5. Peering and Transit Gateway
  6. VPC endpoints (and why they cut bills)
  7. Try it: inspect a VPC ($0 lab)
  8. Quick check (quiz)
  9. Gotchas for Azure devs
  10. Project Compass: provision the VPC
  11. Recap & next

The cheat table: Azure networking to AWS

ConceptAzureAWS
Virtual networkVNet (per region)VPC (per region, per account)
SubnetSubnet (a VNet partition)Subnet (lives in exactly one AZ)
Default network ACLNSG (stateful, applied to subnet or NIC)Security Group (stateful, NIC level) + NACL (stateless, subnet level)
"NSG-equivalent"single conceptsplit in two: SG = "who can talk to me?", NACL = "what packets enter/leave this subnet?"
Jump host / admin accessAzure Bastion (managed)Systems Manager Session Manager (no bastion, no SSH key, no port 22)
Hub-and-spokevWAN / VNet hubTransit Gateway (TGW)
PeeringVNet peering (transitive via vWAN)VPC peering (NEVER transitive - this trips Azure devs)
Private link to PaaSPrivate EndpointVPC Endpoint (Gateway for S3/DynamoDB free; Interface for others, hourly + GB)
Public IPv4Public IP (Basic or Standard SKU)Elastic IP (charged when NOT attached, since Feb 2024 also charged when attached)
Outbound from privateNAT Gateway (per VNet, regional)NAT Gateway (per AZ - you generally want one per AZ for HA)
L4/L7 load balancerStandard LB / Application GatewayNLB (L4) / ALB (L7) / GWLB (firewall chaining)
DNSAzure DNS Private ZonesRoute 53 Private Hosted Zones (associated to VPCs)
Default subnet behaviorAll subnets effectively private; you choose to exposeDefault VPC has all-public subnets with 0.0.0.0/0 via IGW. Production-grade VPCs you build yourself
The single biggest mental shift: Azure NSGs are stateful in both directions. AWS Security Groups are stateful too, but AWS layers a second, stateless control on top - the NACL. If you only configure SGs (like NSGs), you're fine; the NACL just sits at "allow all". But the day you try to "tighten the subnet" with a NACL, you'll discover stateless filtering means you must allow ephemeral return ports explicitly. Many wasted hours start there.
What's in a name? - the VPC glossary
VPC
Virtual Private Cloud. Launched August 2009. Before VPC, EC2 instances lived on a flat shared network called "EC2-Classic" - your i-abc instance and a stranger's i-def could see each other's IPs. VPC was AWS retrofitting tenant isolation. EC2-Classic was fully retired in August 2022, only 13 years later.
CIDR
Classless Inter-Domain Routing. The /16 in 10.0.0.0/16 means "the first 16 bits are the network, the remaining 16 are hosts" - 65,536 addresses. Pronounced "cider" by most. AWS VPC CIDR must be between /16 (large) and /28 (16 addresses, tiny).
NAT
Network Address Translation. Lets many private IPs share one public IP for outbound. NAT Gateway is AWS's managed flavor - highly available within an AZ, but if the AZ fails you need a NAT GW in another AZ to keep your other AZs online. Hence the "one NAT per AZ" rule.
IGW
Internet Gateway. A horizontally-scaled, regional component you attach to a VPC. It does NOT do NAT for private subnets - it just lets instances with their own public IP talk to the internet. The IGW is free; the bandwidth out is not.
NACL
Network Access Control List. Subnet-level, stateless packet filter. Has numbered rules (lowest first) and an implicit deny at the end. The "stateless" part means you must allow return traffic explicitly. Most teams leave the default NACL at "allow all" and rely on Security Groups.
ENI
Elastic Network Interface. The virtual NIC attached to an instance, Lambda-in-VPC, or RDS. Each ENI has a private IP, can have a public IP/EIP, and carries the security groups. Instance types cap how many ENIs you can attach - this becomes EKS pod-density math in chapter 10.
EIP
Elastic IP. A static public IPv4 address you "own" in your account. Since Feb 2024 every EIP costs $0.005/hour (about $3.60/month) even when attached. AWS is pushing teams off IPv4 - leave EIPs unattached and they double-charge you.
ALB
Application Load Balancer. L7 (HTTP/HTTPS) load balancer. The "Apache vibes" sibling of NLB (L4, TCP/UDP) and GWLB (L3, for firewall chains). Application Gateway is the closest Azure analog.

VPC anatomy: CIDR, subnets, AZs

ELI5: what's actually in a VPC
A VPC is a big virtual lot. You stake out the property line (the CIDR block, e.g. "this lot is 65,536 addresses wide"). Inside the lot you mark out smaller fenced areas (subnets), one per building block (AZ). Some fenced areas have a gate to the street (public subnets); others only have a back door through a one-way valve (NAT Gateway). The whole lot is in one city (region); the buildings are in different neighborhoods (AZs) so a single power outage doesn't take everything down.

Three things define every VPC

CIDR block

The IP address range, fixed at creation. 10.0.0.0/16 = 65,536 addresses. AWS allows /16 down to /28. You cannot change the primary CIDR after the VPC exists - you can only add secondary CIDRs.

Azure analog: VNet address space (multiple ranges supported, can be edited).

Subnets per AZ

Each subnet lives in one AZ and carves a slice of the VPC CIDR. Typical layout: one public + one private subnet per AZ, spread across 2-3 AZs. Each subnet is either public or private based on its route table.

Azure analog: Azure subnets are zone-redundant by default; AWS forces you to choose an AZ.

Route tables

A subnet's route table is what determines public vs private. Has a default route for 0.0.0.0/0: pointed at an IGW = public subnet, pointed at a NAT GW = private-with-egress, no default route = fully isolated.

Azure analog: User-defined routes (UDRs) attached to subnets.

A typical production VPC: 2 AZs, 3 tiers VPC compass-vpc - 10.0.0.0/16 Internet Gateway NAT GW (az-a) NAT GW (az-b) optional HA AZ us-east-1a Public 10.0.0.0/24 ALB, NAT GW App 10.0.10.0/24 EC2/Lambda/EKS Data 10.0.20.0/24 RDS, ElastiCache private subnets: 0.0.0.0/0 -> NAT GW public subnet: 0.0.0.0/0 -> IGW AZ us-east-1b Public 10.0.1.0/24 ALB App 10.0.11.0/24 EC2/Lambda/EKS Data 10.0.21.0/24 RDS replica
Two AZs, six subnets, one IGW, one NAT GW (cost-conscious). For full HA you'd add a NAT in az-b too; for dev or non-prod a single NAT is fine.

Picking a CIDR (and the rules)

RangeSizeWhen to use
10.0.0.0/1665,536 IPsDefault mental choice. Plenty of room, easy to remember, won't collide with the typical home network.
172.16.0.0/121M IPs (split into /16s)Useful when you have many VPCs and need a coordinated address plan.
192.168.0.0/1665,536 IPsAvoid - overlaps with most home/office networks, makes VPN setups painful.
A /24 per subnet251 usable IPs (AWS reserves 5)Standard subnet size. AWS reserves the first 4 and last 1 of every subnet.
Fun fact VPC didn't exist when EC2 launched in 2006 - until 2009 every EC2 instance lived on a flat shared network called "EC2-Classic". Your instance and a complete stranger's instance shared a Layer 2 broadcast domain. The default /16 CIDR convention for VPCs is partly a holdover from that era: AWS engineers wanted "big enough you won't run out, small enough not to bite into the broader 10.x space you might want for other VPCs". EC2-Classic was finally shut down in August 2022 - some workloads ran on it for sixteen years.
Fun fact Every instance type has a fixed ENI limit, and each ENI has a fixed secondary IP limit. On EKS the default amazon-vpc-cni plugin gives each pod a real VPC IP from those secondary slots - so a t3.medium (3 ENIs * 6 IPs - 1 primary = 17 pods max) caps pod density purely on network plumbing. Teams hit this before they hit CPU/memory limits and stare at the cluster wondering why pods are Pending. Fixes: bigger instance type, or switch to prefix delegation mode (chapter 10).

Route tables, Internet Gateway, NAT Gateway

A subnet is "public" or "private" only because of its route table. Same subnet, different route table = different connectivity. This is the level Azure abstracts away.

Route table hasSubnet behavior
0.0.0.0/0 -> igw-xxxPublic subnet. Instances need a public IP / EIP to be reachable; they can talk out via IGW.
0.0.0.0/0 -> nat-xxxPrivate-with-egress. Instances have only private IPs; outbound goes through NAT GW.
No 0.0.0.0/0 entryIsolated. Only local VPC traffic and explicit endpoints work. Data tiers often look like this.
0.0.0.0/0 -> tgw-xxxEgress to a Transit Gateway (on-prem via DX/VPN, hub-and-spoke).

Provisioning the routing - side by side

Terraform - azurerm
# Azure: a UDR overriding default routes
resource "azurerm_route_table" "private" {
  name                = "rt-private"
  location            = "eastus"
  resource_group_name = "rg-net"

  route {
    name                   = "default-via-fw"
    address_prefix         = "0.0.0.0/0"
    next_hop_type          = "VirtualAppliance"
    next_hop_in_ip_address = "10.0.5.4"
  }
}

resource "azurerm_subnet_route_table_association" "app" {
  subnet_id      = azurerm_subnet.app.id
  route_table_id = azurerm_route_table.private.id
}
# Implicit: Azure auto-routes to internet unless you override
Terraform - aws
# AWS: a route table, a route, and an association
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "rt-private-a" }
}

resource "aws_route" "private_default" {
  route_table_id         = aws_route_table.private.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.az_a.id
}

resource "aws_route_table_association" "app_a" {
  subnet_id      = aws_subnet.app_a.id
  route_table_id = aws_route_table.private.id
}
# Explicit: AWS has NO default outbound; you must add the route
The default-route inversion. Azure subnets are internet-routed by default; you add a UDR to override. AWS subnets are isolated by default; the only "free" route is the implicit local (intra-VPC). You must explicitly add 0.0.0.0/0 -> igw / nat to get out. This is a feature for security but a footgun for Azure muscle memory.

The NAT Gateway in one diagram

Outbound from a private subnet: the NAT Gateway path Private subnet EC2: 10.0.10.42 no public IP route: 0.0.0.0/0 -> nat-gw NAT Gateway in PUBLIC subnet has EIP 54.x.x.x Internet Gateway attached to VPC Internet return: NAT remembers the flow and rewrites destination back to 10.0.10.42 Source NAT: 10.0.10.42:54321 -> 54.x.x.x:54321 (rewritten by NAT GW) Pricing: per-hour for the NAT GW + per-GB for everything passing through Read: bytes through NAT cost money. Bytes NOT through NAT (via VPC Endpoints) do not.
The NAT Gateway itself lives in a public subnet (so it can reach the IGW). Your private-subnet route table points 0.0.0.0/0 at the NAT.
Bug hunt: "public" subnet that isn't reaching the internet

A teammate created a VPC with Terraform. They added an Internet Gateway, attached it to the VPC, marked one subnet as "public" with map_public_ip_on_launch = true, and launched an EC2 instance with an automatically-assigned public IP. The instance can't reach 0.0.0.0/0. They paste the relevant Terraform below. What's wrong?

main.tf - the broken pieces
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
}

resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.0.0/24"
  availability_zone       = "us-east-1a"
  map_public_ip_on_launch = true
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  # A route for VPC-local traffic to a peering connection
  route {
    cidr_block                = "10.20.0.0/16"
    vpc_peering_connection_id = aws_vpc_peering_connection.shared.id
  }
}

resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}
Click to reveal the bug
The route table has no 0.0.0.0/0 -> igw entry.

Without that default route, the subnet is effectively private with no egress. The IGW exists, the subnet is correctly associated, the instance even has a public IP - but every outbound packet hits a route table that only knows about 10.0.0.0/16 (local, implicit) and 10.20.0.0/16 (the peering). Anything else gets dropped.

Fix: add the missing route.

resource "aws_route" "public_default" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw.id
}

"Public subnet" in AWS is shorthand for "subnet whose route table has 0.0.0.0/0 pointing at an IGW". No route, no public.

Cost trap: the NAT Gateway data-processing bill ~$10K / month surprise

A team runs a 3-AZ HA VPC with three NAT Gateways (one per AZ - the right HA pattern). Baseline is fine: 3 NAT GW * ~$32/month = ~$100/month. Two months later the AWS bill jumps to ~$10,300. The team didn't change architecture, didn't add traffic, didn't change pricing. What happened?

Click to reveal the trap
Two compounding NAT charges no one warned them about.

1. NAT data-processing: $0.045/GB. Every byte that traverses a NAT Gateway is billed at $0.045/GB - in both directions. This is on top of the per-hour cost and on top of internet-egress charges.

2. Inter-AZ traffic accidentally routed through NAT. A new EKS workload talked to an S3 bucket in the same region. By default S3 traffic from private subnets goes out via NAT, to the public S3 endpoint, and back. Worse: the team's pods in az-a were hitting a NAT in az-b because of a misconfigured node selector. Every byte cost $0.045 going out and $0.045 coming back, plus inter-AZ transfer at $0.01/GB. At ~30TB/day, the monthly bill landed around $10K.

Fix:

  • Add a VPC Gateway Endpoint for S3. Free. Routes S3 traffic privately, bypasses NAT entirely.
  • Pin pods to use the NAT in their own AZ (topology-aware routing).
  • For non-prod, consolidate to one NAT GW and accept the AZ-failure risk. Saves $64/month plus avoids accidental cross-AZ NAT bills.
  • For tiny dev traffic, NAT instances (a t4g.nano running iptables -t nat) can be 50x cheaper - the trade-off is you operate it.

"NAT GW costs surprised us" is the #1 AWS cost incident story on r/aws. Read the NAT entry on the bill carefully every month.

Security Groups vs NACLs

ELI5: two layers of fence
A Security Group is the bouncer at your house door who remembers who he let in. If he lets your friend in to deliver pizza, he automatically lets the friend out - that's stateful. A NACL is a checkpoint at the neighborhood gate who has a clipboard with rules but no memory. He checks every car going in and every car going out, both directions, every time. If you only put rules for "cars coming in", the cars trying to leave get stopped. That's stateless.

Security Group

Stateful, ENI-level. Default deny on inbound, default allow on outbound. Return traffic for allowed inbound is automatically allowed. Up to 60 inbound + 60 outbound rules per SG; up to 5 SGs per ENI.

Closest Azure analog: NSG attached to a NIC.

NACL

Stateless, subnet-level. Numbered rules (low to high), explicit Allow/Deny, implicit deny at end. Return traffic must be allowed explicitly (ephemeral ports!). One NACL per subnet; default NACL allows all.

Closest Azure analog: NSG attached to a subnet, but stateless - Azure has nothing identical.

How they combine

For a packet to flow it must pass both. NACL evaluates first on inbound (at the subnet boundary), then SG (at the instance ENI). On outbound, SG first, then NACL. Either layer can deny.

Belt + suspenders. Most teams set the NACL to "allow all" and rely on SGs.

A packet's journey: NACL then SG (inbound), SG then NACL (outbound) Internet NACL (subnet) stateless 100 ALLOW tcp 443 in 200 ALLOW tcp 1024-65535 out * DENY (implicit) must allow return ports explicitly SG (ENI) stateful ALLOW tcp 443 from 0.0.0.0/0 (outbound: allow all default) return traffic auto-allowed EC2 app:443 1 2 3 Inbound HTTPS: NACL allow 443 in -> SG allow 443 -> EC2 handles request Return packet: SG remembers the flow (auto-allow) -> NACL needs ephemeral port rule (1024-65535)
Default NACLs are "allow all in both directions", so most teams never hit the stateless problem - until they tighten the NACL and traffic mysteriously breaks.

SG vs NSG: the subtle traps

BehaviorAzure NSGAWS Security Group
StatefulYes - both directionsYes - both directions (same as Azure)
Default inboundDeny (except AllowVNetInBound which lets intra-VNet through)Deny (no implicit "allow VPC" rule)
Default outboundAllow (with implicit AllowInternetOutBound)Allow 0.0.0.0/0 - first thing many lock down
Reference another security primitiveApplication Security Group (ASG)Security Group itself - "allow from sg-abc" as the source
Priority-basedYes - lowest number winsNo priority - all rules are OR'd; if any allows, packet passes
Explicit Deny ruleYes - Deny action with priorityNO - SG rules are allow-only. To deny, you simply omit. (NACL is where deny lives.)
Default subnet rulesNSG attached either to subnet or NIC; default is "no NSG"Every subnet has a NACL (default = allow all); every ENI has an SG (default = allow VPC traffic out only)
The trap that catches every Azure dev: Security Groups have no Deny rule. You can't write "allow 10.0.0.0/16 except 10.0.5.0/24". You either omit, or you use a NACL for the Deny. Once you internalize this, you stop trying to make SGs do NSG things and your policies get simpler.

Side-by-side definitions

Azure - NSG
resource "azurerm_network_security_group" "web" {
  name                = "nsg-web"
  location            = "eastus"
  resource_group_name = "rg-app"

  security_rule {
    name                       = "allow-https"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}
AWS - Security Group
resource "aws_security_group" "web" {
  name        = "sg-web"
  description = "web tier"
  vpc_id      = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "https" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "tcp"
  from_port         = 443
  to_port           = 443
}

resource "aws_vpc_security_group_egress_rule" "out" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"   # all protocols
}
Real-world pattern "SSH open to 0.0.0.0/0" - compromised in 4 minutes compromised in 4 min

This isn't one story - it's the pattern. A developer launches an EC2 instance for a quick test. The wizard asks about an SSH security group; they pick "Anywhere (0.0.0.0/0)" because they want to SSH from a coffee shop. The instance gets a public IP, the SG allows port 22 from the world.

Honeytoken and SSH-honeypot studies (most widely cited: the 2021 Palo Alto Networks Unit 42 honeypot research) consistently show that an SSH endpoint on a fresh public AWS IP starts receiving credential-stuffing attempts within minutes. Median time-to-first-attack across multiple cloud honeypot studies: under 4 minutes. Common outcomes once a weak password (or worse, a leaked SSH key) is guessed: crypto miners running on the instance, the instance used as a jump box into the rest of the VPC, IAM credentials exfiltrated via IMDS if IMDSv1 is enabled.

The blast radius depends on what else is on that instance. Stories range from "$3K of crypto-mining EC2 charges in 48 hours before the alert tripped" to "attacker pivoted via IMDS, found an admin role, deleted production".

Lessons: (1) Never expose port 22 to 0.0.0.0/0. Use Systems Manager Session Manager - no port 22, no public IP, no SSH key. (2) Enforce IMDSv2 on every instance (the v1 endpoint is unauthenticated). (3) Use SCPs to block 0.0.0.0/0 on port 22 ingress entirely at the org level. We wire this up in chapters 8 and 12.

Peering and Transit Gateway (vs Azure vWAN)

Two VPCs need to talk. You have two options: a direct peering connection or a Transit Gateway hub. Azure's mental model maps cleanly, but one rule is loudly different.

VPC Peering

Direct, point-to-point. One peering per pair of VPCs. Same region or cross-region. Update both route tables to add the peer's CIDR. Cheap (no hourly fee, only data transfer).

Azure analog: VNet peering. Same idea, but Azure peerings can be "use remote gateway" - AWS peerings can't.

Transit Gateway (TGW)

Hub-and-spoke. Attach many VPCs and VPNs to one TGW. Acts as a regional router. Hourly + per-GB cost, but scales to dozens or hundreds of VPCs without N-squared peerings.

Azure analog: Azure vWAN / VNet hub.

The CRITICAL rule

VPC peering is NEVER transitive. If A peers with B and B peers with C, A still cannot talk to C. You need a third peering A-C, or you put all three on a TGW. Azure has the same rule officially, but vWAN abstracts it.

VPC peering (left) vs Transit Gateway (right) Peering: N*(N-1)/2 connections VPC A VPC B VPC C 3 VPCs = 3 peerings 10 VPCs = 45 peerings A to C is direct - peering A-B does NOT relay Transit Gateway: N connections TGW regional hub VPC A VPC B VPC C VPN/DX Any VPC talks to any other via TGW
For more than 4-5 VPCs, TGW wins on operational simplicity even though it costs more per GB. Below that, peering is cheaper and simpler.

Cost comparison (us-east-1, ballpark)

ConnectivityHourly feeData transfer
VPC peering (same region)$0$0.01/GB (each direction, inter-AZ only)
VPC peering (cross-region)$0$0.02/GB
Transit Gateway attachment~$0.05/hour per attachment (~$36/mo each)$0.02/GB through TGW
vWAN hub (Azure equivalent)~$0.25/hour per hub$0.02/GB
Picking between them: Default to peering until you're stitching together >5 VPCs or you need to share an on-prem connection (Direct Connect / VPN) across many VPCs. Then move to TGW. Mixing both is normal: TGW for the network backbone, a couple of peerings for high-throughput special cases where you don't want to pay TGW's $0.02/GB.

VPC Endpoints (and why they cut bills)

ELI5: VPC endpoints
Imagine your house wants to talk to the post office. Normally you walk out the front door, down the public sidewalk, and into the post office's front door. That round trip is "via NAT and the internet" - it works, but you pay the city per step. A VPC endpoint is like a private door inside your house that opens directly into the post office's loading dock. Same conversation, no public street, no per-step toll. Azure calls the same idea a "Private Endpoint".

VPC endpoints connect a VPC privately to AWS services without traversing the public internet (and without going through your NAT Gateway). Two flavors, very different pricing.

Gateway endpoint

FREE. Only available for S3 and DynamoDB. Works by adding a special route in your subnet's route table. Traffic to S3/DynamoDB matches the route and goes via the gateway endpoint instead of 0.0.0.0/0 -> NAT.

Always add these. Free. Cuts NAT bills immediately.

Interface endpoint

Paid. Available for ~200 AWS services (SQS, KMS, Secrets Manager, CloudWatch, ECR, STS, etc). Creates an ENI in your subnet with a private IP that resolves the service's hostname. Costs ~$0.01/hour per endpoint per AZ + $0.01/GB.

Used heavily by workloads that don't want NAT or need on-prem access to AWS APIs via Direct Connect.

The savings math

If your private workload does 1 TB/month to S3 via NAT: 1024 GB * $0.045 = ~$46/month in NAT data processing alone, plus inter-AZ. Same TB via a Gateway Endpoint: $0. The endpoint pays for itself in seconds.

Closest Azure analog: Private Endpoint for storage accounts.

Path A: via NAT (costs $$). Path B: via VPC Endpoint (often free). EC2 (private) wants S3 NAT GW $0.045/GB IGW free Internet route to S3 endpoint S3 (public endpoint) s3.us-east-1.amazonaws.com A: ~$0.045/GB through NAT GW + bandwidth out Gateway Endpoint (S3) routes via VPC, FREE S3 (private path) same bucket, same DNS B: FREE - no NAT, no public internet, traffic stays on AWS backbone
Gateway Endpoints work via route table magic. The S3 SDK still uses the same DNS name; the route table sends matching traffic via the endpoint, bypassing NAT and IGW.

Adding a Gateway Endpoint in Terraform

endpoints.tf
# Gateway endpoint for S3 - FREE, no ENIs, just a route entry
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.us-east-1.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [
    aws_route_table.private_a.id,
    aws_route_table.private_b.id,
  ]
}

# Gateway endpoint for DynamoDB - also free
resource "aws_vpc_endpoint" "dynamodb" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.us-east-1.dynamodb"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [
    aws_route_table.private_a.id,
    aws_route_table.private_b.id,
  ]
}

# Interface endpoint for KMS - paid (~$7/month/AZ)
# But it lets workloads use KMS without going through NAT
resource "aws_vpc_endpoint" "kms" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.us-east-1.kms"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [aws_subnet.app_a.id, aws_subnet.app_b.id]
  security_group_ids  = [aws_security_group.endpoints.id]
  private_dns_enabled = true
}
Project Compass rule: for any VPC that hosts workloads, add the two free Gateway Endpoints (S3 + DynamoDB) on day one. It costs nothing, it works retroactively (existing code doesn't change), and it removes the most common cause of NAT cost surprises.

Try it: inspect your default VPC

Lab: list VPCs, examine the default, follow a route table $0

Goal: get comfortable with the inspection commands. We won't create anything in this lab - that's chapter 3's Project Compass slice. Here we just look at what's already there. Every new AWS account ships with one default VPC per region.

Step 1. List all VPCs in your current region.

terminal
aws ec2 describe-vpcs \
  --query 'Vpcs[].{Id:VpcId,Cidr:CidrBlock,Default:IsDefault,State:State}' \
  --output table

# +----------------+-------------+---------+-----------+
# |     Id         |    Cidr     | Default |   State   |
# +----------------+-------------+---------+-----------+
# | vpc-0abcd1234  | 172.31.0.0/16| True    | available |
# +----------------+-------------+---------+-----------+

Step 2. List the default VPC's subnets - one per AZ.

terminal
VPC_ID=$(aws ec2 describe-vpcs \
  --filters "Name=isDefault,Values=true" \
  --query 'Vpcs[0].VpcId' --output text)

aws ec2 describe-subnets \
  --filters "Name=vpc-id,Values=$VPC_ID" \
  --query 'Subnets[].{Id:SubnetId,Cidr:CidrBlock,AZ:AvailabilityZone,Public:MapPublicIpOnLaunch}' \
  --output table

# Note Public=True on every default subnet. That's why default VPCs aren't production-grade.

Step 3. Look at the main route table.

terminal
aws ec2 describe-route-tables \
  --filters "Name=vpc-id,Values=$VPC_ID" \
  --query 'RouteTables[0].Routes' \
  --output table

# You'll see two routes:
#   10.x.x.x/16 -> local        (intra-VPC, implicit)
#   0.0.0.0/0   -> igw-xxxx     (the default VPC has an IGW pre-attached)

Step 4. Look at the default security group attached to that VPC.

terminal
aws ec2 describe-security-groups \
  --filters "Name=vpc-id,Values=$VPC_ID" "Name=group-name,Values=default" \
  --query 'SecurityGroups[0].{IngressRules:IpPermissions,EgressRules:IpPermissionsEgress}'

# Default SG ingress: allow from itself only (any member of this SG can talk to any other member)
# Default SG egress: 0.0.0.0/0 (allow all out)
# Default NACL: allow all in both directions

What you learned: the default VPC is a public, all-AZ, all-traffic-allowed playground. Great for quickstart tutorials, dangerous for production. The whole purpose of chapter 3's Project Compass slice is to build a non-default VPC that follows the production patterns.

Quick check

Test yourself - 5 questions

VPC questions are about defaults and rules. Sit with each one before revealing.

1. Which statement about Security Groups and Azure NSGs is true?

  • NSGs are stateful, SGs are stateless.
  • Both are stateful in both directions; SGs additionally have no Deny rule.
  • SGs are stateful inbound and stateless outbound.
  • NSGs and SGs both support priority numbers.
Show answer
Answer: b. Both NSGs and SGs are stateful in both directions - if traffic is allowed one way, the return is auto-allowed. The big differences: SGs have no explicit Deny rule (rules are allow-only, OR'd together) and no priority - while NSGs have both. To express "allow except X" in AWS you use a NACL or you simply don't list X.

2. A NACL has rules: 100 ALLOW tcp 443 from 0.0.0.0/0 and 110 ALLOW tcp 80 from 0.0.0.0/0. An admin adds 105 DENY tcp 443 from 1.2.3.0/24. What's the effect on a packet from 1.2.3.4:54321 to port 443?

  • Allowed - rule 100 matches first.
  • Denied - rule 105 matches before rule 100 because of lower number.
  • Denied - rule 105 matches before rule 110 by number ordering; rule 100 is evaluated first but rule 105 has the more specific deny.
  • Denied - rule 105 (number 105 < 110, but > 100) is evaluated AFTER 100 in numeric order, so the ALLOW at 100 wins.
Show answer
Answer: d. NACLs evaluate rules in ascending numeric order and the FIRST match wins. Rule 100 (ALLOW 443 from all) is evaluated before rule 105 (DENY 443 from 1.2.3.0/24). The packet matches rule 100, gets allowed, and rule 105 is never reached. To actually deny 1.2.3.0/24, the Deny needs a number lower than 100 (say, 90). Numeric ordering of NACL rules is a common operational bug.

3. You're running a 3-AZ HA app in a private subnet. Which NAT topology is correct for HA?

  • One NAT Gateway in a public subnet in any AZ - cheaper and good enough.
  • One NAT Gateway in each AZ, with each private subnet's route table pointing to the NAT in its own AZ.
  • Two NAT Gateways, one in az-a and one in az-c (skip az-b to save money).
  • A single NAT Gateway is multi-AZ by default, so any setup is fine.
Show answer
Answer: b. NAT Gateways are AZ-scoped services - HA within an AZ but not across. If you put one NAT in az-a and az-a fails, your az-b and az-c workloads still need egress. For real HA you place one NAT per AZ and each private subnet sends 0.0.0.0/0 to its same-AZ NAT. Bonus: this avoids the $0.01/GB inter-AZ data transfer fee on outbound. For non-prod, option (a) is fine and saves ~$64/month.

4. VPC A peers with VPC B. VPC B peers with VPC C. From an instance in VPC A, you ping an instance in VPC C. What happens?

  • Works - peering is transitive once both peerings exist.
  • Works - VPC B acts as a router.
  • Fails - VPC peering is never transitive; you need A-C peering directly or a Transit Gateway.
  • Works only if VPC B is the "hub" VPC.
Show answer
Answer: c. VPC peering is fundamentally non-transitive. There's no concept of a "hub VPC" in plain peering. To connect more than a couple of VPCs you either add an explicit peering for every pair (N*(N-1)/2 peerings) or attach all VPCs to a Transit Gateway. Azure vWAN hides this, but the underlying VNet peering rule is identical.

5. You launch an EC2 instance in your account's default VPC, accept the wizard defaults. Which is true?

  • The instance is launched in a private subnet by default.
  • The instance gets a public IP and the route table sends 0.0.0.0/0 via IGW; SSH/RDP from anywhere is allowed if you pick the "default" SG.
  • The instance has no internet access until you attach an Elastic IP.
  • The default VPC has no IGW; you must attach one.
Show answer
Answer: b. The default VPC is fully public: every default subnet has MapPublicIpOnLaunch=true, the main route table has 0.0.0.0/0 -> igw, and the default Security Group allows all egress. If during the wizard you pick "Allow SSH from anywhere", you've just put a port-22-open instance on the public internet - the horror-story scenario. The default VPC is for quickstarts; production VPCs should be hand-built (or Terraformed - see Project Compass below).

Gotchas for Azure devs

1. Security Groups are stateful in BOTH directions - just like NSGs. So far, so similar. The trap: SGs have no Deny rule. You can only Allow. To express deny logic you either omit (the default-deny) or push the Deny to the NACL layer. Stop trying to write NSG-style "allow except" in SG rules.
2. VPC peering is NEVER transitive. A-B + B-C does NOT give you A-C. Azure vWAN abstracts this away with the hub-and-spoke model. AWS makes you choose: explicit per-pair peerings, or move to a Transit Gateway. Discover this before you've built 12 peerings and need a 13th.
3. The default VPC has 0.0.0.0/0 open to the internet on every subnet. "Default VPC" sounds like "safe sensible default" - it isn't. It's "everything wide open for tutorials". Either delete the default VPCs in every region you don't use, or use SCPs (chapter 12) to deny default-VPC usage entirely.
4. VPC CIDR cannot be changed after creation. Pick wisely - 10.0.0.0/16 is a good default with room to grow. You CAN add secondary CIDR blocks later, but the primary is set in stone. Worse: you cannot peer two VPCs with overlapping CIDRs at all. This is the single most common reason a "small dev VPC" needs to be rebuilt from scratch later.
5. NAT Gateway is per-AZ, not regional. A NAT GW lives in exactly one AZ. If the AZ goes down, all subnets routed through that NAT lose egress. For real HA you need one NAT per AZ, with each private subnet's route table pointing at its own AZ's NAT. The pattern is "1 NAT per AZ", not "1 NAT per VPC".

Project Compass: provision the VPC for gfn-reports

Last chapter you created gfn-reports-role. The Lambda we'll build in chapter 4 will (eventually) need to live in a VPC so it can reach an RDS database in chapter 6 and an ElastiCache in chapter 9. Time to build the network.

Project Compass · Step 3 of 12 Build compass-vpc - a 2-AZ VPC with public + private subnets

Why now: networking is foundational, and provisioning it once means every later chapter can drop resources into the existing VPC. We do it cost-conscious: a single NAT GW (not three), only the free S3 + DynamoDB Gateway Endpoints, no interface endpoints yet.

This chapter's slice - Terraform
compass-vpc/main.tf - VPC and subnets
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region  = "us-east-1"
  profile = "compass"
  default_tags {
    tags = { Project = "Compass", ManagedBy = "terraform" }
  }
}

resource "aws_vpc" "main" {
  cidr_block           = "10.50.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = { Name = "compass-vpc" }
}

locals {
  azs = ["us-east-1a", "us-east-1b"]
}

# Public subnets - one per AZ. 10.50.0.0/24 and 10.50.1.0/24.
resource "aws_subnet" "public" {
  for_each                = toset(local.azs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.50.${index(local.azs, each.key)}.0/24"
  availability_zone       = each.key
  map_public_ip_on_launch = true
  tags = { Name = "compass-public-${each.key}", Tier = "public" }
}

# Private subnets - 10.50.10.0/24 and 10.50.11.0/24.
resource "aws_subnet" "private" {
  for_each          = toset(local.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.50.${10 + index(local.azs, each.key)}.0/24"
  availability_zone = each.key
  tags = { Name = "compass-private-${each.key}", Tier = "private" }
}
compass-vpc/gateway.tf - IGW + single NAT GW (cost-conscious)
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "compass-igw" }
}

# EIP for the NAT GW
resource "aws_eip" "nat" {
  domain     = "vpc"
  depends_on = [aws_internet_gateway.igw]
  tags       = { Name = "compass-nat-eip" }
}

# Single NAT GW in az-a (dev-grade; for prod, one per AZ)
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public["us-east-1a"].id
  tags          = { Name = "compass-nat" }
  depends_on    = [aws_internet_gateway.igw]
}
compass-vpc/routes.tf - public + private route tables
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }
  tags = { Name = "compass-rt-public" }
}

resource "aws_route_table_association" "public" {
  for_each       = aws_subnet.public
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }
  tags = { Name = "compass-rt-private" }
}

resource "aws_route_table_association" "private" {
  for_each       = aws_subnet.private
  subnet_id      = each.value.id
  route_table_id = aws_route_table.private.id
}

# Free Gateway Endpoints (S3 + DynamoDB) - day-one cost saver
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.us-east-1.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.private.id]
  tags              = { Name = "compass-s3-endpoint" }
}

resource "aws_vpc_endpoint" "dynamodb" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.us-east-1.dynamodb"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.private.id]
  tags              = { Name = "compass-ddb-endpoint" }
}
compass-vpc/security_groups.tf - basic SGs
# SG for the Lambda when it runs in the VPC (chapter 4)
resource "aws_security_group" "lambda" {
  name        = "compass-lambda-sg"
  description = "egress-only SG for gfn-reports Lambda"
  vpc_id      = aws_vpc.main.id
  tags        = { Name = "compass-lambda-sg" }
}

resource "aws_vpc_security_group_egress_rule" "lambda_all" {
  security_group_id = aws_security_group.lambda.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

# SG placeholder for the future RDS (chapter 6). Allow tcp/5432 from Lambda SG only.
resource "aws_security_group" "rds" {
  name        = "compass-rds-sg"
  description = "allow Postgres from Lambda SG"
  vpc_id      = aws_vpc.main.id
  tags        = { Name = "compass-rds-sg" }
}

resource "aws_vpc_security_group_ingress_rule" "rds_from_lambda" {
  security_group_id            = aws_security_group.rds.id
  referenced_security_group_id = aws_security_group.lambda.id
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
}
terminal - apply and verify
# Apply
cd compass-vpc/
terraform init
terraform plan
terraform apply

# Verify
aws ec2 describe-vpcs \
  --filters "Name=tag:Project,Values=Compass" \
  --query 'Vpcs[].{Id:VpcId,Cidr:CidrBlock,Tags:Tags[?Key==`Name`].Value|[0]}' \
  --output table --profile compass

aws ec2 describe-subnets \
  --filters "Name=tag:Project,Values=Compass" \
  --query 'Subnets[].{Id:SubnetId,AZ:AvailabilityZone,Cidr:CidrBlock,Public:MapPublicIpOnLaunch}' \
  --output table --profile compass

aws ec2 describe-vpc-endpoints \
  --filters "Name=tag:Project,Values=Compass" \
  --query 'VpcEndpoints[].{Service:ServiceName,Type:VpcEndpointType,State:State}' \
  --output table --profile compass

You now have a clean, non-default VPC with two AZs, four subnets (2 public + 2 private), one IGW, one NAT GW, two free Gateway Endpoints, and two security groups wired up for the future Lambda-to-RDS flow. The estimated monthly cost: about $36 (one NAT GW + EIP), before any data transfer.

Progress
ch1 · profile ch2 · IAM role ch3 · VPC ch4 · Lambda ch5 · S3 ch6 · DynamoDB ch7 · SQS ch8 · KMS ch9 · alarms ch10 · EKS ch11 · API GW ch12 · Terraform
Want to save the $36/month while not actively learning? Run terraform destroy when you're between chapters. The state file is tiny; re-applying takes ~3 minutes. The only thing you can't destroy-and-recreate freely is the VPC CIDR (which is the point of doing it via IaC - the recipe stays the same).

Recap & next

What stuck?

The mental model in one sentence

Azure networking starts open and you lock down with NSGs. AWS networking starts locked and you open up with route tables, gateways, and security group rules - explicitly, layer by layer. More verbose, more visible, more places to forget a step. Once you build the same VPC three times you'll stop forgetting.

Common pitfalls so far

TrapFix
"Public" subnet but no internetRoute table needs 0.0.0.0/0 -> igw. Existence of an IGW isn't enough.
Huge NAT GW billAdd S3 + DynamoDB Gateway Endpoints (free). Audit inter-AZ NAT routing. Single NAT for non-prod.
"My SG has a Deny rule that doesn't work"SGs are allow-only. Use a NACL for Deny, or omit the rule.
"A can reach B, B can reach C, A can't reach C"Peering isn't transitive. Add A-C peering or move to Transit Gateway.
"I want to change my VPC CIDR"You can't. Add a secondary CIDR, or rebuild the VPC. Plan address space up front.
NEXT CHAPTER
4. Compute (EC2, Lambda, Fargate)
EC2 instances, Lambda functions, Fargate tasks - and where each one fits versus Azure VMs, Functions, and Container Apps.