AWS VPC: Public vs Private Subnets, Security Groups vs NACLs
9 min readBytePatterns
What makes an AWS subnet public, how private subnets reach out through NAT, and how stateful security groups differ from stateless NACLs, with a toy model.
"Put the database in a private subnet" opens many AWS architecture answers. In a VPC, "private" is not a checkbox; it is the absence of one route. Two firewalls sit on top of the routing, and mixing them up breaks traffic in confusing ways. Everything below comes from the Amazon VPC documentation pages listed at the end, as of September 2026.
The problem it solves
A typical web application needs three kinds of reachability at once:
- The load balancer must be reachable from the internet.
- The app servers must be reachable from the load balancer and able to call out, without the internet calling in.
- The database must be reachable from the app servers only.
A VPC answers with two mechanisms. Routes decide where a packet can go. Firewalls, security groups and network ACLs, decide whether it may.
The intuition
A subnet is a range of IP addresses in the VPC, and each subnet sits entirely in one Availability Zone. Every subnet is associated with a route table, and the route table decides its type:
- Public: the route table has a direct route to an internet gateway, usually
0.0.0.0/0. - Private: no direct route to an internet gateway. To reach the internet, traffic goes through a NAT device.
- Isolated: no routes to anything outside the VPC at all.
When several routes match, the most specific one wins, the longest prefix match. The local route for the VPC's own range therefore always beats 0.0.0.0/0 for internal traffic.
A NAT gateway lets instances in a private subnet start connections to services outside the VPC, while outside services cannot start connections to them. The standard, zonal kind lives in one Availability Zone, in a public subnet with an Elastic IP, and the private subnet's default route points at it. AWS also offers regional NAT gateways, which expand across zones automatically and need no public subnet.
Then the two firewalls:
- Security groups work at the instance level: allow rules only, all evaluated before a decision, and stateful, so the reply to allowed traffic is allowed automatically. A new group has no inbound rules and allows all outbound. A rule can name another security group as its source, so "the app servers" is a rule, not a list of IPs.
- Network ACLs work at the subnet level: numbered allow and deny rules, evaluated in ascending order, first match decides, and stateless, so replies need their own rules. Every subnet has one; the default allows everything, and each ends in a
*rule denying whatever nothing else matched.
Watch it run
The animation draws the route tables as boxes of their own, because "public" lives in a route. It lights up 0.0.0.0/0 to the internet gateway, then sends a user's request through the gateway to the load balancer and on to an app server in a private subnet. The app's outbound call follows the private route table to the NAT gateway. The final frames compare the two firewalls by what happens to the reply.
VPC: Subnets, NAT & Firewalls
Step 1 of 11
A VPC is your private network in one Region. Each subnet lives in exactly one Availability Zone.
The same interactive animation as the lesson — step through it with the controls.
The code
The lesson's commands, with placeholder IDs: default routes to the internet gateway and the NAT gateway, and a database rule that references the app servers' security group:
# illustrative: IDs are placeholders
aws ec2 create-route --route-table-id rtb-public \
--destination-cidr-block 0.0.0.0/0 --gateway-id igw-0abc
aws ec2 create-route --route-table-id rtb-private \
--destination-cidr-block 0.0.0.0/0 --nat-gateway-id nat-0abc
aws ec2 authorize-security-group-ingress --group-id sg-db \
--protocol tcp --port 5432 --source-group sg-app
A toy model of the documented rules, not AWS itself. Every subnet loses five addresses, the first four and the last, and the most specific route wins:
import ipaddress
def usable_ips(cidr):
"""AWS reserves the first four addresses and the last one in every subnet."""
return ipaddress.ip_network(cidr).num_addresses - 5
print(usable_ips("10.0.1.0/24"), usable_ips("10.0.2.0/28")) # 251 11
def route(table, dest):
"""Toy model: the most specific matching route wins (longest prefix)."""
ip = ipaddress.ip_address(dest)
matches = [(ipaddress.ip_network(cidr), target) for cidr, target in table
if ip in ipaddress.ip_network(cidr)]
return max(matches, key=lambda m: m[0].prefixlen)[1] if matches else None
public_rt = [("10.0.0.0/16", "local"), ("0.0.0.0/0", "igw-0abc")]
private_rt = [("10.0.0.0/16", "local"), ("0.0.0.0/0", "nat-0abc")]
for dest in ["10.0.2.15", "203.0.113.7"]:
print(dest, route(public_rt, dest), route(private_rt, dest))
# 10.0.2.15 local local
# 203.0.113.7 igw-0abc nat-0abc
Then the two firewalls. The NACL's deny for port 22 has a lower number than the broad allow, so it wins; the security group has no deny at all, only the absence of an allow:
def nacl_allows(rules, port):
"""Toy model: rules checked in ascending number, first match decides,
and the '*' rule denies anything left over."""
for number, lo, hi, action in sorted(rules):
if lo <= port <= hi:
return action == "allow"
return False # the '*' default rule
def sg_allows(rules, port):
"""Toy model: allow rules only, all evaluated; any match allows."""
return any(lo <= port <= hi for lo, hi in rules)
inbound = [(100, 443, 443, "allow"), (90, 22, 22, "deny"), (200, 0, 65535, "allow")]
print([nacl_allows(inbound, p) for p in (443, 22, 8080)]) # [True, False, True]
print([sg_allows([(443, 443)], p) for p in (443, 22)]) # [True, False]
Statelessness in one example. A server replies to the client's ephemeral port, which the client picks; the VPC guide lists 49152–65535 for recent Windows and 1024–65535 for Elastic Load Balancing. A NACL allowing only outbound 443 drops the reply; a security group would not:
outbound_nacl = [(100, 443, 443, "allow")] # replies go to an ephemeral port
print(nacl_allows(outbound_nacl, 49152)) # False
outbound_nacl.append((110, 1024, 65535, "allow"))
print(nacl_allows(outbound_nacl, 49152)) # True
Both models against independent references on 2,000 random cases: routing tries every prefix length from /32 down and takes the first hit, and the NACL reference takes the lowest-numbered matching rule directly:
import random
def route_reference(table, dest):
"""Try prefix lengths from /32 down to /0; the first hit is the answer."""
ip = ipaddress.ip_address(dest)
for length in range(32, -1, -1):
net = ipaddress.ip_network(f"{ip}/{length}", strict=False)
for cidr, target in table:
if ipaddress.ip_network(cidr) == net:
return target
return None
def nacl_reference(rules, port):
hits = [r for r in rules if r[1] <= port <= r[2]]
return bool(hits) and min(hits)[3] == "allow"
random.seed(5)
ok = True
for _ in range(2000):
table, seen = [], set()
for i in range(random.randint(0, 6)):
length = random.choice([0, 8, 16, 24, 32])
net = ipaddress.ip_network(f"10.{random.randint(0, 1)}.{random.randint(0, 1)}.0/{length}"
if length else "0.0.0.0/0", strict=False)
if net not in seen: # no duplicate destinations
seen.add(net)
table.append((str(net), f"t{i}"))
dest = f"10.{random.randint(0, 1)}.{random.randint(0, 1)}.{random.randint(0, 1)}"
ok &= route(table, dest) == route_reference(table, dest)
numbers = random.sample(range(1, 50), random.randint(0, 6))
rules = [(n, lo, lo + random.randint(0, 5), random.choice(["allow", "deny"]))
for n, lo in zip(numbers, [random.randint(0, 20) for _ in numbers])]
port = random.randint(0, 25)
ok &= nacl_allows(rules, port) == nacl_reference(rules, port)
print(ok) # True
The complexity
The costs here are money and operations:
- NAT gateways are charged per hour available and per gigabyte processed. Traffic to Amazon S3 and DynamoDB can use gateway endpoints, which have no additional charge.
- Cross-zone traffic through a NAT gateway adds data transfer charges.
- Security groups have no additional charge, and referencing a group keeps rules stable as instances come and go.
- Subnet size runs from
/28to/16, minus the five reserved addresses.
Where it goes wrong
- Forgetting NACL return traffic. Every allowed request needs a rule for the reply on ephemeral ports.
- Numbering a deny after an allow. The first matching rule wins, so a narrow deny needs a lower number than the broad allow.
- Expecting a security group to block something. It has no deny rules; use a NACL.
- One zonal NAT gateway for many zones. If its zone goes down, resources in the other zones lose internet access. Use one per zone, or a regional NAT gateway.
- Opening SSH to
0.0.0.0/0. The guide recommends allowing ports 22 and 3389 only from specific address ranges.
How to say it in an interview
"A subnet lives in one Availability Zone and is public only if its route table sends 0.0.0.0/0 to an internet gateway. App servers and databases go in private subnets; outbound calls go through a NAT gateway, and S3 or DynamoDB traffic through a gateway endpoint. Security groups are the main control: stateful, allow-only, and able to reference each other, so the database allows 5432 from the app's group. NACLs are stateless, numbered allow and deny rules on the subnet, first match wins; I keep them coarse, as a second layer."
Who may change these rules is IAM's job, covered in AWS IAM policy evaluation, and the full picture is in design a system on AWS.
Sources
- Subnets for your VPC — Amazon VPC User Guide
- Subnet CIDR blocks — Amazon VPC User Guide
- How route priority works — Amazon VPC User Guide
- NAT gateways, NAT gateway basics and regional NAT gateways — Amazon VPC User Guide
- Pricing for NAT gateways — Amazon VPC User Guide
- Compare security groups and network ACLs — Amazon VPC User Guide
- Security groups and security group rules — Amazon VPC User Guide
- Network ACL rules and custom network ACLs — Amazon VPC User Guide
- Gateway endpoints — AWS PrivateLink