AWS VPC Subnets

By Vishvesh Patel · DevOps EngineerUpdated September 15, 2026

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 containsSubnet is
0.0.0.0/0 → Internet GatewayPublic
0.0.0.0/0 → NAT GatewayPrivate, with outbound internet
No 0.0.0.0/0 routeIsolated

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

AddressUsed for
.0Network address
.1VPC router
.2DNS
.3Reserved for future use
.255Broadcast (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.

CIDRTotalUsable
/281611
/24256251
/221,0241,019
/204,0964,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.

TagValueOn
kubernetes.io/role/elb1Public subnets, for internet-facing load balancers
kubernetes.io/role/internal-elb1Private subnets, for internal load balancers
kubernetes.io/cluster/<name>shared or ownedBoth, 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

TaskCommand
List with free IPsaws ec2 describe-subnets --query 'Subnets[].{ID:SubnetId,AZ:AvailabilityZone,Free:AvailableIpAddressCount}'
Createaws ec2 create-subnet --vpc-id <vpc> --cidr-block <cidr> --availability-zone <az>
Which route tableaws ec2 describe-route-tables --filters Name=association.subnet-id,Values=<subnet>
Associate a route tableaws ec2 associate-route-table --subnet-id <s> --route-table-id <rtb>
Auto-assign public IPsaws ec2 modify-subnet-attribute --subnet-id <s> --map-public-ip-on-launch
Available zonesaws ec2 describe-availability-zones --query 'AvailabilityZones[].ZoneName'
Tag a subnetaws 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 /28 gives 11 usable, not 16.
  • CIDR blocks are immutable. Use /20 by 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

Exercise

Fewer addresses than expected

A /28 subnet is created for 12 instances but only 11 can launch. Why?

Exercise

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.

Continue Learning

Explore Related Topics

When it breaks

Related Resources