Skip to content

Public vs private subnets in AWS: how a subnet actually becomes public

AWS has no public checkbox on a subnet. A subnet is public when its route table points 0.0.0.0/0 at an internet gateway and the instance carries a public IP. Here is the exact mechanism and how to audit yours.

There is no attribute on an AWS subnet called “public”. A subnet is public because of one thing: the route table associated with it contains a route for 0.0.0.0/0 whose target is an internet gateway. That is the whole mechanism. Everything else — the “Public subnet” label in the VPC wizard, the auto-assign public IP setting, the naming convention in your Terraform — is convention layered on top of that single route.

What’s happening

Three conditions have to hold before an instance in a subnet can talk to the internet directly, and people routinely check only one of them:

  1. The VPC has an internet gateway attached. An IGW is a VPC-level object; it does nothing until a route points at it.
  2. The subnet’s route table has 0.0.0.0/0 -> igw-xxxx. If the subnet has no explicit route table association, it inherits the VPC’s main route table — which may or may not have that route. This inheritance is the source of most accidental exposure and most accidental isolation.
  3. The instance has a public IPv4 address — either auto-assigned at launch (the subnet attribute MapPublicIpOnLaunch) or an Elastic IP. The IGW performs one-to-one NAT between the public IP and the instance’s private IP; without a public IP there is nothing to translate, and inbound traffic has no address to reach.

A private subnet is simply a subnet whose route table has no IGW route. If it needs outbound internet, its default route points at a NAT gateway sitting in a public subnet instead — the trade-offs of that piece are covered in NAT Gateway vs NAT instance. Security groups and NACLs then filter what routing has permitted; they never make a subnet public or private by themselves.

What to check first

  • Which route table is the subnet actually using? Explicit association or falling through to the main route table — the console shows this on the subnet’s Route table tab.
  • Does that table have 0.0.0.0/0 to an igw-? Not to a nat-, not blackholed.
  • Does the instance have a public IP right now? Stopping and starting an instance releases an auto-assigned public IP (Elastic IPs persist).
  • Is MapPublicIpOnLaunch set the way you think? New instances in a “public” subnet without it silently launch unreachable.
  • Is the main route table carrying an IGW route? If yes, every subnet you never explicitly associated is public-routable — usually a surprise.

How to verify

Console path: VPC > Subnets > select subnet > Route table tab, then VPC > Route tables > Routes. From the CLI:

# Which route table serves this subnet (falls back to main if empty)
aws ec2 describe-route-tables \
  --filters "Name=association.subnet-id,Values=subnet-0abc123" \
  --query 'RouteTables[].Routes[]'

# The VPC's main route table — what unassociated subnets inherit
aws ec2 describe-route-tables \
  --filters "Name=vpc-id,Values=vpc-0abc123" "Name=association.main,Values=true" \
  --query 'RouteTables[].Routes[]'

# Does the subnet hand out public IPs at launch?
aws ec2 describe-subnets --subnet-ids subnet-0abc123 \
  --query 'Subnets[].MapPublicIpOnLaunch'

# Is an IGW attached to the VPC at all?
aws ec2 describe-internet-gateways \
  --filters "Name=attachment.vpc-id,Values=vpc-0abc123"

An instance in a genuinely public subnet shows a route entry with GatewayId: igw-... for 0.0.0.0/0 and a PublicIpAddress in describe-instances. The full attribute reference lives in the Amazon VPC documentation.

The runbook

  1. Decide the subnet’s role first, then make routing match. Load balancers, NAT gateways, and bastion hosts belong in public subnets; application servers and databases belong in private ones. If you are laying out a fresh VPC, follow a per-AZ plan like the one in multi-AZ subnet design rather than improvising.
  2. Create explicit route tables for every subnet tier. One “public” table with the IGW route, one “private” table per AZ pointing at that AZ’s NAT gateway. Associate every subnet explicitly and leave the main route table with only the local route, so an unassociated subnet defaults to isolated instead of exposed.
  3. Set MapPublicIpOnLaunch deliberately. Enable it on public subnets where instances should be reachable at launch (aws ec2 modify-subnet-attribute --subnet-id subnet-0abc123 --map-public-ip-on-launch); keep it off everywhere else and use Elastic IPs for anything that must keep a stable address.
  4. To make a public subnet private, do it in order. Move or re-create workloads that need inbound reachability, swap the route table association to the private table, then remove public IPs. Changing the route out from under a live instance drops its established internet sessions immediately.
  5. Confirm with a real packet. From an instance in the subnet, curl -m 5 https://checkip.amazonaws.com — success through an IGW returns the instance’s own public IP; through NAT it returns the NAT gateway’s EIP. That one command tells you which path you are actually on.

When this is the wrong move

Making a subnet public because “the app needs internet access” is the classic error — outbound-only workloads belong in private subnets behind NAT or, for AWS service traffic, behind VPC endpoints. Equally, forcing everything private is wrong when the component’s job is to accept internet traffic; an internet-facing ALB in a private subnet simply does not work. And if your real problem is instances being unreachable, check security groups before touching routing — a correct route with a closed security group looks identical from the outside.

Common mistakes

  • Treating the subnet’s name (“public-a”) as truth instead of reading the route table.
  • Leaving the IGW route on the main route table, silently making every future subnet public-routable.
  • Assuming a public IP survives stop/start — auto-assigned addresses do not; only Elastic IPs do.
  • Putting a NAT gateway in a private subnet — it needs an IGW route itself, so it must live in a public subnet.

Prevention

  • Keep the main route table empty of everything except the local route; associate all subnets explicitly.
  • Tag route tables and subnets with their tier (public, private-app, private-data) and audit that tags match routes quarterly.
  • Enforce the layout in Terraform or CloudFormation so a new AZ or subnet inherits the correct associations instead of hand-built ones.
  • Alarm on new 0.0.0.0/0 -> igw routes via AWS Config or CloudTrail — that change should be rare and deliberate.

The public/private distinction is one route entry, which is exactly why it deserves an explicit audit rather than trust in naming conventions. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.