AWS: Zero to Hero — Week 6: Networking Deep Dive — How Traffic Actually Finds Your Stuff
Week 6 of learning AWS from zero, with Claude as my research partner. VPCs, subnets, Security Groups vs NACLs, the 2024 public IPv4 charge, Route 53, and CloudFront explained. Then the real lab: a hand-built public/private VPC with a working NAT Gateway, tiered Security Groups, a NACL proven stateless on purpose, a free S3 endpoint, a private Route 53 zone, and a CloudFront distribution caught caching live.
Series: AWS: Zero to Hero (9 articles)
- …
- 6AWS: Zero to Hero — Week 6: Networking Deep Dive — How Traffic Actually Finds Your Stuff This article
Everything so far — compute, storage, databases — has been about things that exist. This week is about how anything actually reaches them. Turns out the answer is a surprisingly deliberate set of rules about who's allowed to talk to whom, and a real economic twist I wasn't expecting: as of a couple of years ago, AWS started charging you just for the privilege of having a public IP address at all.
The Deep Dive
VPC: your own private slice of a Region
A VPC (Virtual Private Cloud) is your own logically isolated network inside an AWS Region — nothing inside it is reachable by anyone else's VPC unless you explicitly connect them. It's defined by an IP address range (a CIDR block), and it spans every AZ in that Region, though the resources inside it live in specific AZs via subnets.
Creating a VPC itself is free — no hourly charge, no per-resource cost, and the same goes for subnets, route tables, and security groups. The costs, as I found out this week, all live in what you attach to the VPC, not the VPC itself.
Subnets: public vs. private, and what actually makes the difference
A subnet is a slice of your VPC's address range, and it lives in exactly one AZ — tying this straight back to Week 2's Availability Zones. What makes a subnet "public" or "private" isn't a checkbox, it's entirely about its route table: a public subnet has a route sending internet-bound traffic (0.0.0.0/0) to an Internet Gateway; a private subnet doesn't.
- Internet Gateway (IGW) — attached at the VPC level, lets resources in a public subnet both initiate connections to the internet and be reached from it (assuming a public IP and permissive security group).
- NAT Gateway — sits in a public subnet on behalf of a private one. It lets private-subnet resources initiate outbound connections (installing a package, calling an external API) without ever being reachable from the internet themselves. The asymmetry is the entire point: your database server can go fetch a security patch, but nobody outside can ever initiate a connection to it directly.
That asymmetry is also, it turns out, a real cost center: a NAT Gateway runs $0.045/hour plus $0.045/GB of data it processes — worth knowing before you architect something that pushes a lot of traffic through one by accident.
Security Groups vs. Network ACLs — the distinction that trips everyone up first
Both control traffic, but at different layers and with a genuinely different behavior model:
- Security Groups — attached to individual resources (an EC2 instance, an RDS database), and stateful: if you allow outbound traffic on a rule, the response traffic is automatically allowed back in, no matching inbound rule needed.
- Network ACLs (NACLs) — attached at the subnet level, and stateless: inbound and outbound rules are entirely independent, so you have to explicitly allow both directions yourself, or an otherwise-permitted connection just silently fails on the return trip.
In practice, most day-to-day traffic control happens through Security Groups; NACLs mostly show up as a coarser, subnet-wide backstop rule or an explicit deny.
The public IPv4 economics twist
This one genuinely surprised me. Since February 2024, AWS charges for every public IPv4 address in your account — $0.005/hour, roughly $3.65/month — whether it's attached to a running resource or sitting completely idle. That covers EC2 public IPs, Elastic IPs, load balancers, NAT Gateways, RDS instances, anything with a public IPv4 address. Previously, only idle, unattached Elastic IPs were charged; now everything is.
AWS's own stated reasoning is IPv4 address scarcity (the cost of acquiring new address blocks has risen sharply), and the charge is a deliberate nudge toward two things: cleaning up public IPs you're not actually using, and moving toward IPv6, which carries no equivalent hourly charge at all. It's a small line item per address, but it's exactly the kind of thing that quietly adds up across a real architecture — and it's a very current, concrete example of Week 1's "pay for what you use" cutting both ways: you also pay for what you forgot to clean up.
VPC Endpoints: reaching AWS services without touching the public internet at all
There's a cheaper, more private alternative to routing everything through a NAT Gateway when your traffic is actually just headed to another AWS service: VPC Endpoints.
- Gateway Endpoints — available for S3 and DynamoDB specifically, and completely free. No hourly charge, no data charge.
- Interface Endpoints (PrivateLink) — for most other AWS services, priced per-AZ per-hour plus a small per-GB charge, but still generally cheaper than routing that same traffic through a NAT Gateway, and it never touches the public internet at all.
For anything talking primarily to S3 or DynamoDB from a private subnet, a Gateway Endpoint is close to a free upgrade over NAT Gateway traffic — both cheaper and more private.
Route 53: how a URL actually becomes a server
Route 53 is AWS's DNS service — the system that turns a domain name into an actual IP address. Beyond simple lookups, it supports several routing policies worth knowing exist:
- Simple routing — one record, one answer, no logic.
- Weighted routing — split traffic by percentage across multiple targets (useful for gradual rollouts).
- Latency-based routing — send users to whichever Region actually responds fastest for them.
- Geolocation routing — route based on the user's actual location (useful for content restrictions or compliance).
- Failover routing — automatically redirect to a backup target if health checks on the primary fail.
This is the literal first step of "how does typing a URL end up loading my app" — DNS resolution happens before a single byte of your actual traffic goes anywhere.
CloudFront: the edge locations from Week 2, doing their actual job
Back in Week 2, Edge Locations were introduced as AWS's answer to "get content physically close to users." CloudFront is the CDN service that actually uses them: it caches content at whichever edge location is nearest a given user, so a request doesn't have to travel all the way back to your origin Region every single time.
One genuinely current detail: data transferred from an S3 bucket to a CloudFront distribution in the same AWS account is now free — a change from late 2024 — which makes "S3 origin plus a CloudFront distribution in front of it" a meaningfully cheaper default pattern for serving static content than it used to be.
Putting the whole traffic flow together
Here's the full picture, stitched from everything so far: someone types your URL. Route 53 resolves it to an IP. That request hits the nearest CloudFront edge location — served instantly from cache if it's there, or pulled from your actual origin if not. If it needs your application, it reaches an Application Load Balancer (Week 4) sitting in a public subnet, protected by a Security Group, inside your VPC. The ALB forwards it to an EC2 instance living in a private subnet — no public IP, unreachable directly from outside, exactly the way it should be. That instance, in turn, talks to an RDS database (Week 5) in another private subnet, reaching it entirely inside the VPC. Any outbound package updates that instance needs go out through a NAT Gateway, never accepting a single inbound connection from the internet in return.
Every single piece of that sentence is something covered somewhere in the last six weeks. That's genuinely the moment this stopped feeling like a list of unrelated services and started feeling like one actual system.
Cost Reality Check for This Week
VPC, subnets, route tables, security groups, NACLs, and Gateway VPC Endpoints: entirely free, on any account. Route 53 hosted zones normally bill around $0.50/month, but AWS doesn't charge at all for one deleted within 12 hours of creation. CloudFront's Always Free tier covers 1TB of data transfer and 10 million requests a month, so testing a distribution costs nothing. The one to actually watch this week: a NAT Gateway plus the Elastic IP it needs runs roughly $0.05/hour combined — a few cents for this lab if cleaned up promptly, real money if it's accidentally left running for a week.
Hands-On: Build a Real Public/Private VPC From Scratch
This is the lab the last two weeks kept deferring by using the default VPC. Not this time — everything below gets built by hand: two AZs, real public and private subnets, actual routing, a working NAT Gateway, a private DNS record, and a CDN distribution that genuinely caches.
Part 1 — Create the VPC and enable DNS
1
2
3
4
5
6
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
--query 'Vpc.VpcId' --output text)
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-support '{"Value":true}'
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-hostnames '{"Value":true}'
aws ec2 create-tags --resources $VPC_ID --tags Key=Name,Value=zero-to-hero-vpc
Part 2 — Public and private subnets, across two AZs
1
2
3
4
5
6
7
8
9
10
11
12
AZ1=$(aws ec2 describe-availability-zones --query 'AvailabilityZones[0].ZoneName' --output text)
AZ2=$(aws ec2 describe-availability-zones --query 'AvailabilityZones[1].ZoneName' --output text)
PUB_SUBNET_1=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.0.0/24 \
--availability-zone $AZ1 --query 'Subnet.SubnetId' --output text)
PUB_SUBNET_2=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.1.0/24 \
--availability-zone $AZ2 --query 'Subnet.SubnetId' --output text)
PRIV_SUBNET_1=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.10.0/24 \
--availability-zone $AZ1 --query 'Subnet.SubnetId' --output text)
PRIV_SUBNET_2=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.11.0/24 \
--availability-zone $AZ2 --query 'Subnet.SubnetId' --output text)
Part 3 — Internet Gateway, and the route table that actually makes the public subnets public
1
2
3
4
5
6
7
8
IGW_ID=$(aws ec2 create-internet-gateway --query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --vpc-id $VPC_ID --internet-gateway-id $IGW_ID
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 --subnet-id $PUB_SUBNET_1 --route-table-id $PUB_RT
aws ec2 associate-route-table --subnet-id $PUB_SUBNET_2 --route-table-id $PUB_RT
This is the exact mechanic from the theory section above: nothing makes a subnet "public" except its route table pointing 0.0.0.0/0 at an Internet Gateway.
Part 4 — NAT Gateway (the part that actually costs money)
1
2
3
4
5
6
EIP_ALLOC=$(aws ec2 allocate-address --domain vpc --query 'AllocationId' --output text)
NAT_ID=$(aws ec2 create-nat-gateway --subnet-id $PUB_SUBNET_1 \
--allocation-id $EIP_ALLOC --query 'NatGateway.NatGatewayId' --output text)
aws ec2 wait nat-gateway-available --nat-gateway-ids $NAT_ID
That
wait matters — NAT Gateways take a few minutes to provision, and billing starts from creation, not from "available."1
2
3
4
5
PRIV_RT=$(aws ec2 create-route-table --vpc-id $VPC_ID --query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $PRIV_RT --destination-cidr-block 0.0.0.0/0 --nat-gateway-id $NAT_ID
aws ec2 associate-route-table --subnet-id $PRIV_SUBNET_1 --route-table-id $PRIV_RT
aws ec2 associate-route-table --subnet-id $PRIV_SUBNET_2 --route-table-id $PRIV_RT
Part 5 — Security Groups, tiered by identity, not just IP range
1
2
3
4
5
6
7
8
9
WEB_SG=$(aws ec2 create-security-group --group-name zero-to-hero-web \
--description "Web tier - public" --vpc-id $VPC_ID --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id $WEB_SG \
--protocol tcp --port 80 --cidr 0.0.0.0/0
DB_SG=$(aws ec2 create-security-group --group-name zero-to-hero-db \
--description "DB tier - private, web-only" --vpc-id $VPC_ID --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id $DB_SG \
--protocol tcp --port 5432 --source-group $WEB_SG
That last rule is the important one: the database's Security Group only accepts traffic from the web tier's Security Group specifically — not from an IP range, from an identity. Nothing except something wearing
zero-to-hero-web can ever reach port 5432, regardless of which subnet it's launched in.Part 6 — Prove NACLs are actually stateless, instead of taking it on faith
1
2
3
4
5
6
7
8
NACL_ID=$(aws ec2 create-network-acl --vpc-id $VPC_ID --query 'NetworkAcl.NetworkAclId' --output text)
# Allow inbound HTTP only — deliberately no outbound rule
aws ec2 create-network-acl-entry --network-acl-id $NACL_ID \
--rule-number 100 --protocol tcp --port-range From=80,To=80 \
--cidr-block 0.0.0.0/0 --rule-action allow --ingress
aws ec2 associate-network-acl --network-acl-id $NACL_ID --subnet-id $PUB_SUBNET_1
With no outbound rule, this NACL's default deny-all silently blocks every response trying to leave that subnet — a request can arrive, but its response can never get back out. Launch a test instance here and curl it, and the connection hangs and times out, even though inbound was explicitly allowed. That's the whole Security-Group-vs-NACL distinction from the theory section, demonstrated instead of described. (Detach this NACL once you've seen it fail — leaving it attached breaks anything real launched into this subnet afterward.)
Part 7 — A free Gateway Endpoint for S3, instead of routing through the NAT Gateway
1
2
3
aws ec2 create-vpc-endpoint --vpc-id $VPC_ID \
--service-name com.amazonaws.<your-region>.s3 \
--route-table-ids $PRIV_RT
Anything in the private subnets can now reach S3 directly — no NAT Gateway hop, no per-GB processing charge, and traffic that never touches the public internet at all.
Part 8 — Route 53 Private Hosted Zone (no owned domain needed)
You don't need a real registered domain to try this — a private hosted zone only resolves inside your own VPC:
1
2
3
4
aws route53 create-hosted-zone \
--name zero-to-hero.internal \
--vpc VPCRegion=<your-region>,VPCId=$VPC_ID \
--caller-reference "z2h-$(date +%s)"
Add an A record pointing at something real inside the VPC (the NAT Gateway's private IP works as a quick test target), then query it from an instance launched in this VPC — it resolves, purely internally, the exact mechanism from the theory section, minus a public-facing domain.
Delete this within 12 hours — Route 53 hosted zones normally bill roughly $0.50/month, but AWS doesn't charge at all for one deleted inside that grace period.
Part 9 — A CloudFront distribution (genuinely free to test)
Point CloudFront at any public S3 bucket still lying around from Week 5:
1
2
3
aws cloudfront create-distribution \
--origin-domain-name <your-bucket>.s3.amazonaws.com \
--default-root-object index.html
Once it deploys (a few minutes — CloudFront is slower to spin up than most services), curl it twice and check the response headers:
1
curl -I https://<your-distribution-id>.cloudfront.net/test.txt
The
X-Cache header tells the real story: Miss from cloudfront on the first request, Hit from cloudfront on the second — the edge-caching behavior from the theory section, visible in a single header instead of taken on faith.Cleanup — do this in order, and prioritize step 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 1. NAT Gateway + its Elastic IP FIRST — this is the one billing by the minute
aws ec2 delete-nat-gateway --nat-gateway-id $NAT_ID
aws ec2 wait nat-gateway-deleted --nat-gateway-ids $NAT_ID
aws ec2 release-address --allocation-id $EIP_ALLOC
# 2. Route 53 hosted zone (delete the record first, then the zone)
aws route53 delete-hosted-zone --id <zone-id>
# 3. CloudFront (must be disabled before it can be deleted — fetch ETag, disable, wait, then delete)
aws cloudfront get-distribution-config --id <distribution-id>
aws cloudfront delete-distribution --id <distribution-id> --if-match <etag-from-above>
# 4. VPC Endpoint
aws ec2 delete-vpc-endpoints --vpc-endpoint-ids <endpoint-id>
# 5. Everything else
aws ec2 delete-route-table --route-table-id $PUB_RT
aws ec2 delete-route-table --route-table-id $PRIV_RT
aws ec2 detach-internet-gateway --vpc-id $VPC_ID --internet-gateway-id $IGW_ID
aws ec2 delete-internet-gateway --internet-gateway-id $IGW_ID
aws ec2 delete-subnet --subnet-id $PUB_SUBNET_1
aws ec2 delete-subnet --subnet-id $PUB_SUBNET_2
aws ec2 delete-subnet --subnet-id $PRIV_SUBNET_1
aws ec2 delete-subnet --subnet-id $PRIV_SUBNET_2
aws ec2 delete-security-group --group-id $DB_SG
aws ec2 delete-security-group --group-id $WEB_SG
aws ec2 delete-network-acl --network-acl-id $NACL_ID
aws ec2 delete-vpc --vpc-id $VPC_ID
If you only have time to do one thing immediately after finishing this lab, make it step 1 — the NAT Gateway keeps billing every minute it exists, completely independent of what state the rest of the cleanup is in.
My Take
This article was a great refresher for my Computer Networks (CN) concepts :) I also got to learn a lot of new things along the way, including VPCs, Route 53, Security Groups, NACL, CloudFront, and more.
Next week — Part 7: Serverless and event-driven AWS — Lambda, API Gateway, SQS, SNS, and EventBridge, and what it actually means to build something without managing a single server.
Series: AWS: Zero to Hero (9 articles)
- …
- 6AWS: Zero to Hero — Week 6: Networking Deep Dive — How Traffic Actually Finds Your Stuff This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article