AWS VPC Subnets
Designing VPC subnets. Public versus private, CIDR sizing and the five reserved IPs, availability zone placement, required tags, and running out of addresses.
A subnet is a slice of your VPC's address range, bound to exactly one Availability Zone. Subnet design is one of the few AWS decisions that is genuinely hard to undo, so it is worth a little thought before the first terraform apply.
A subnet is public because of its route table
Nothing about the subnet object makes it public or private. A subnet is public if its route table sends 0.0.0.0/0 to an Internet Gateway. That is the entire definition.
| Route table contains | Subnet is |
|---|---|
0.0.0.0/0 → Internet Gateway | Public |
0.0.0.0/0 → NAT Gateway | Private, with outbound internet |
No 0.0.0.0/0 route | Isolated |
A resource in a public subnet also needs a public IP to be reachable. The route makes the subnet public; the IP makes the instance reachable. Those are two separate conditions and both are required.
aws ec2 describe-route-tables \
--filters Name=association.subnet-id,Values=subnet-0abc123 \
--query 'RouteTables[].Routes[?DestinationCidrBlock==`0.0.0.0/0`]'
A subnet with no explicit route table association falls back to the VPC's main route table, which is a common cause of a subnet that behaves unexpectedly. Associate every subnet explicitly.
One subnet, one Availability Zone
A subnet cannot span zones. To be multi-AZ you create one subnet per zone per tier:
10.0.0.0/16 VPC
├── 10.0.0.0/20 public-a eu-west-1a
├── 10.0.16.0/20 public-b eu-west-1b
├── 10.0.32.0/20 public-c eu-west-1c
├── 10.0.48.0/20 private-a eu-west-1a
├── 10.0.64.0/20 private-b eu-west-1b
├── 10.0.80.0/20 private-c eu-west-1c
├── 10.0.96.0/22 database-a eu-west-1a
└── 10.0.100.0/22 database-b eu-west-1b
Three tiers across three zones is the standard shape: public for load balancers and NAT gateways, private for application workloads, and a separate isolated tier for databases.
Query zones rather than hardcoding them, since zone names differ per account:
aws ec2 describe-availability-zones --region eu-west-1 \
--query 'AvailabilityZones[].ZoneName' --output text
AWS reserves five addresses in every subnet
| Address | Used for |
|---|---|
.0 | Network address |
.1 | VPC router |
.2 | DNS |
.3 | Reserved for future use |
.255 | Broadcast (reserved, not supported) |
So a /28 gives you 11 usable addresses, not 16. On small subnets this matters a great deal, and it is a frequent surprise when a /28 intended for four instances runs out sooner than arithmetic suggested.
| CIDR | Total | Usable |
|---|---|---|
/28 | 16 | 11 |
/24 | 256 | 251 |
/22 | 1,024 | 1,019 |
/20 | 4,096 | 4,091 |
Size generously: you cannot resize
A subnet's CIDR block is immutable. Changing it means creating a new subnet and migrating everything into it, which for anything with attached resources is a project rather than an edit.
Since private address space costs nothing, the sensible default is /20 per subnet in a /16 VPC. That leaves room for growth and for subnets you have not thought of yet.
On EKS this is not optional. The AWS VPC CNI gives every pod a real VPC IP address, so pod density is bounded by subnet size. A /24 sounds ample until 200 pods exhaust it and new pods fail with failed to assign an IP address to container:
aws ec2 describe-subnets \
--filters Name=vpc-id,Values=vpc-0abc123 \
--query 'Subnets[].{ID:SubnetId,AZ:AvailabilityZone,CIDR:CidrBlock,Free:AvailableIpAddressCount}' \
--output table
Check that Free column before scaling anything, and enable prefix delegation on the CNI to raise pod density substantially.
If you do exhaust the VPC, you can attach a secondary CIDR block, but ranges must not overlap anything you might later peer with, which is why the initial choice matters.
Tags that are functionally required
Some AWS services discover where to place resources by reading subnet tags. Miss them and things fail quietly.
| Tag | Value | On |
|---|---|---|
kubernetes.io/role/elb | 1 | Public subnets, for internet-facing load balancers |
kubernetes.io/role/internal-elb | 1 | Private subnets, for internal load balancers |
kubernetes.io/cluster/<name> | shared or owned | Both, for EKS discovery |
An Ingress stuck in pending with no useful error is very often a missing subnet tag. The AWS Load Balancer Controller logs say it could not discover subnets, which is the clue.
Tag by purpose too, Tier = public, Tier = private, so automation and cost reporting can distinguish them.
Auto-assign public IP
aws ec2 modify-subnet-attribute --subnet-id subnet-0abc123 --map-public-ip-on-launch
Enable it on public subnets so instances get an address automatically. Leave it off on private subnets. Enabling it there does not grant internet access, since there is no Internet Gateway route, but it does consume public IPv4 addresses, which are now chargeable.
That charge is worth knowing about: AWS bills for every public IPv4 address, attached or not. Instances that do not need one should not have one.
Database subnets
A third isolated tier for databases is worth the extra structure. It has no route to the internet at all, not even through NAT, so a compromised application cannot exfiltrate data outward from the database tier.
RDS requires a DB subnet group spanning at least two Availability Zones, even for a single-AZ instance, so you always need at least two database subnets.
Quick reference
| Task | Command |
|---|---|
| List with free IPs | aws ec2 describe-subnets --query 'Subnets[].{ID:SubnetId,AZ:AvailabilityZone,Free:AvailableIpAddressCount}' |
| Create | aws ec2 create-subnet --vpc-id <vpc> --cidr-block <cidr> --availability-zone <az> |
| Which route table | aws ec2 describe-route-tables --filters Name=association.subnet-id,Values=<subnet> |
| Associate a route table | aws ec2 associate-route-table --subnet-id <s> --route-table-id <rtb> |
| Auto-assign public IPs | aws ec2 modify-subnet-attribute --subnet-id <s> --map-public-ip-on-launch |
| Available zones | aws ec2 describe-availability-zones --query 'AvailabilityZones[].ZoneName' |
| Tag a subnet | aws ec2 create-tags --resources <s> --tags Key=kubernetes.io/role/elb,Value=1 |
Key takeaways
- The route table makes a subnet public, not any property of the subnet itself.
- A subnet lives in exactly one Availability Zone. Multi-AZ means one subnet per zone per tier.
- AWS reserves 5 addresses per subnet: a
/28gives 11 usable, not 16. - CIDR blocks are immutable. Use
/20by default; resizing later is a migration. - EKS consumes one VPC IP per pod, so undersized subnets stop pods scheduling.
- Subnet tags are functionally required for EKS and load balancer discovery.
- Do not auto-assign public IPs on private subnets: no benefit, and IPv4 addresses are chargeable.
- RDS needs a DB subnet group across two zones, even for single-AZ.
Check your understanding
Fewer addresses than expected
A /28 subnet is created for 12 instances but only 11 can launch. Why?
A public subnet with no internet
An instance in a subnet named "public-a" has a public IP but cannot reach the internet. What should you check?
Frequently Asked Questions
What makes a subnet public or private in AWS?
Only its route table. A subnet is public when its route table sends 0.0.0.0/0 to an Internet Gateway, and private when it does not, typically routing that traffic to a NAT gateway instead. There is no property on the subnet itself that marks it either way, and the name is just a tag. A resource in a public subnet additionally needs a public IP to be reachable from outside, so "public subnet" and "reachable instance" are two separate conditions.
How many IP addresses does an AWS subnet actually provide?
Five fewer than the CIDR suggests. AWS reserves the network address, the VPC router, the DNS resolver, one address for future use, and the broadcast address in every subnet regardless of size. So a /28 provides 11 usable addresses rather than 16, a /24 provides 251, and a /20 provides 4,091. The reservation is proportionally most painful on small subnets.
What size should my subnets be?
Larger than feels necessary, because CIDR blocks are immutable and private address space costs nothing. A /20 per subnet inside a /16 VPC is a sensible default, leaving room for growth. This matters especially for EKS, where the VPC CNI assigns every pod a real VPC IP address. A /24 that looks generous will stop pods scheduling once a few hundred are running. Check AvailableIpAddressCount before scaling.
Can I change a subnet's CIDR block after creating it?
No. Subnet CIDR blocks are immutable, so changing one means creating a new subnet with the desired range and migrating resources into it, which for anything with attached network interfaces, load balancer registrations or database subnet group membership is a project rather than an edit. If the VPC itself runs out of space you can attach a secondary CIDR block, provided the new range does not overlap anything you might peer with later.
Why is my EKS Ingress stuck in pending?
Almost always missing subnet tags. The AWS Load Balancer Controller discovers where to place a load balancer by looking for kubernetes.io/role/elb=1 on public subnets and kubernetes.io/role/internal-elb=1 on private ones. Without them it has nowhere to put the load balancer and fails quietly. Check the controller logs, kubectl logs -n kube-system deploy/aws-load-balancer-controller, which will say it could not discover subnets.
Should I enable auto-assign public IP on private subnets?
No. It does not grant internet access, since that comes from the route table, but it does allocate a public IPv4 address to every instance launched there, and AWS charges for every public IPv4 address, attached or not. Enable it on public subnets where instances genuinely need to be reachable, and leave it off everywhere else. Instances in private subnets reach the internet through a NAT gateway using only their private address.