A VPC is not a network you can walk. It is a set of API objects whose contents are compiled into forwarding and filtering state on the hypervisor beside every instance. The general shape of that model, and the arguments about address planning and stateful versus stateless filtering, are developed in cloud networking architecture. This article is the AWS instantiation: the specific objects, the attribute names you will type, the addresses AWS takes without telling you, and the handful of quotas that end up shaping designs. Nearly every VPC incident resolves to one of these objects saying something other than what its owner believed it said.

The resource model, and what the default VPC hands you

A VPC is regional. It spans every Availability Zone in that region, and it is the only object in the model with that property. A subnet is pinned to exactly one AZ and cannot be moved, which is why zone redundancy is expressed as one subnet per zone per tier and why a mature account contains dozens of nearly identical subnet definitions.

The object that actually carries policy is the elastic network interface. An ENI holds the private address, the security groups, and the source/destination check flag, and it lives in exactly one subnet. Everything that appears to be inside a VPC is really one or more ENIs: an EC2 instance, an ALB or NLB node in each AZ, an RDS instance, an interface VPC endpoint, an EFS mount target, a Lambda function configured for VPC access, a Transit Gateway attachment. This is why capacity questions are ENI questions, not instance questions.

Every account starts with a default VPC per region: a 172.31.0.0/16 range, one /20 subnet per AZ, an internet gateway, a main route table with 0.0.0.0/0 pointing at it, and auto-assign public IPv4 on every subnet. That is why tutorials work first time and why so much production traffic ends up in a network nobody designed. It also has enableDnsHostnames set to true, which a VPC you create does not — the source of a whole class of differences between a working demo and a broken landing zone.

VPC network structureVPC (10.0.0.0/16)region-wideSubnetsper-AZ CIDRsGatewaysIGW, NAT, VGWRoute tables decide traffic paths; SGs and NACLs enforce security at ENI and subnet levels
VPC building blocks.
Advertisement

CIDR sizing and the five addresses AWS takes from every subnet

A VPC CIDR block must be between /16 and /28, and so must each subnet. You cannot resize the primary block after creation. Secondary CIDR blocks can be attached — the default quota is small and raisable — but the rules about which ranges may sit alongside a given primary are fiddly enough to check before designing around one rather than after.

Inside each subnet AWS reserves five addresses, and the arithmetic trips people every time. For 10.0.1.0/24:

10.0.1.0    network address
10.0.1.1    VPC router (the implied default gateway)
10.0.1.2    reserved for DNS (the resolver itself sits at VPC base + 2)
10.0.1.3    reserved for future use
10.0.1.255  broadcast address (reserved; VPC does not support broadcast)
            -> 251 usable, not 256, and 11 usable in a /28

What consumes those addresses is rarely the instance count. EKS with the VPC CNI gives every pod a routable VPC address and nodes pre-allocate a warm pool, so burn tracks pods and node churn; prefix delegation, which hands each ENI /28 prefixes instead of single addresses, exists because the per-ENI address counts on most instance types run out first. Internal load balancers, interface endpoints and mount targets each take an address per AZ. Teams running out commonly attach a secondary range from the carrier-grade NAT space 100.64.0.0/10 for pod networking, keeping the routable range for what must be reachable.

IPv6 works differently: an Amazon-provided block is a /56 for the VPC and every subnet must be a /64. There is no address scarcity and no NAT, which means an instance with an IPv6 address and a route to an internet gateway is reachable inbound from the internet unless a security group says otherwise.

Route tables — the main table, associations, and the local route

Routing inside a VPC is longest-prefix match over a table of destination/target pairs. Every table is created with one route you did not write: the VPC CIDR pointing at local. It cannot be deleted, and it is what makes intra-VPC reachability free: anything in the VPC can address anything else in it before a single route is written.

You can install a route more specific than local to force traffic through an inspection appliance, under real constraints: the destination must match the entire CIDR block of a subnet in the VPC, and the target must be a NAT gateway, a network interface, or a Gateway Load Balancer endpoint. Local itself is never removable.

Each VPC has a main route table, and a subnet with no explicit association silently uses it. That is the most common way a subnet ends up public by accident: an internet gateway route added to the main table for one subnet is inherited by every unassociated subnet in the VPC. No field on a subnet marks it public or private — the label is shorthand for what its route table does with unmatched traffic, nothing more.

Three more mechanics. Routes are either static or propagated from a virtual private gateway, and propagation is a per-table flag that is off by default. That flag is the whole of the most common hybrid ticket there is: a Site-to-Site VPN or Direct Connect circuit whose tunnels are up and whose BGP session has learned every on-premises prefix still delivers nothing, because no private subnet table was told to accept them. It presents as a VPN problem and is a route table problem. A route whose target no longer exists becomes a blackhole — traffic disappears silently rather than erroring, which is how an incomplete decommission presents. And a route table can be associated with a gateway rather than a subnet (edge association), which is how ingress traffic is steered into an appliance before it reaches the workload. The default quota is 50 routes per table.

Internet gateway, NAT gateway, egress-only gateway

An internet gateway is a region-wide component with no bandwidth constraint you manage. It performs one-to-one NAT between an instance private address and its public IPv4 address. The operating system never sees the public address, which is why binding a service to it fails and why software that advertises its own address has to be told what the public one is. Reaching an instance from the internet requires three independent things: a public address or elastic IP on the ENI, a route to the IGW in the subnet table, and filtering that permits the traffic. Any one missing produces a hang, not an error.

A NAT gateway is the opposite: outbound only, and — unlike the IGW — an AZ-scoped resource living in one public subnet with an elastic IP. One NAT gateway shared across three AZs means egress from the other two crosses an AZ boundary, paying cross-AZ transfer and taking a hard dependency on that zone; the standard build is one per AZ with each private route table pointing at its local one. It also cannot be given a security group, so the only filtering around it is its subnet NACL. Port exhaustion is the classic ceiling — roughly 55,000 simultaneous connections to any one destination endpoint — and the mechanics of that pool are covered in managed NAT gateways; ErrorPortAllocation is the alarm worth having.

For IPv6 there is no NAT, so outbound-only reachability is a separate object: the egress-only internet gateway, stateful, permitting return traffic for connections the VPC initiated while blocking inbound initiation. Note that the NAT gateway and every public IPv4 address in use both carry an hourly charge on top of data processing.

Security groups and NACLs — the AWS-specific parts

Stateful versus stateless, and why intent belongs in the stateful layer, is argued in cloud networking architecture. What is worth knowing here is AWS behaviour that cannot be described generically.

The default security group in every VPC allows all inbound traffic from itself — a self-reference — and all outbound. A security group you create allows nothing inbound and everything outbound. Anything launched without an explicit group gets the default, so a fleet that all lands in the default group is effectively unsegmented while looking configured.

A rule whose source is another security group rather than an address range keeps meaning the right thing across every scale event. That referencing works within a VPC and across same-region peering; it does not work across inter-region peering or a Transit Gateway, which surprises people copying a working design to a second region. Quotas bite too: 60 rules per direction per group, five groups per ENI by default (raisable to sixteen), and a ceiling on the product of the two, so a group grown one address at a time eventually blocks an attachment. Prefix lists, customer-managed or the AWS-managed ones for S3 and DynamoDB, are the way out.

On the stateless side, the default NACL allows everything both ways while one you create denies everything until you write rules. The ephemeral return-port range you must permit depends on the client: Linux typically uses 32768-60999, but traffic originating from a NAT gateway, an Elastic Load Balancer or Lambda can use anything from 1024-65535, so a narrow guess silently breaks one caller and not another.

VPC endpoints — gateway versus interface

Two unrelated mechanisms share the word endpoint.

A gateway endpoint exists only for S3 and DynamoDB. It is not an ENI and has no address: you associate it with route tables, and AWS installs a route whose destination is an AWS-managed prefix list for that service. There is no hourly or data charge. The limitation that matters is that it is a routing construct local to the VPC, so traffic arriving from on-premises over VPN or Direct Connect, from a peered VPC or through a Transit Gateway cannot use it — those paths never evaluate that subnet route table. Hybrid designs usually discover this when on-prem S3 traffic keeps going out to the public endpoint.

An interface endpoint is PrivateLink. AWS places an ENI with a private address in each subnet you nominate. Because it is an ENI it has a security group, and forgetting to permit 443 inbound from the VPC CIDR is the most common reason a new interface endpoint times out. It is billed hourly per AZ plus per gigabyte, and — unlike a gateway endpoint — it is reachable from on-premises and from other VPCs, which is usually the reason to pay for one. The provider and consumer sides of this model are covered in AWS PrivateLink.

Both kinds accept an endpoint policy, an IAM-shaped document restricting what may pass through. The mirror-image control lives on the resource: a bucket policy requiring aws:SourceVpce or aws:SourceVpc makes a bucket unreachable except through your own network — see AWS IAM conditions. Enabling private DNS on an interface endpoint is what lets unmodified SDK code use the standard service hostname and land on the endpoint.

Advertisement

DNS — two attributes, one resolver address, and DHCP option sets

Two VPC attributes control resolution and they do different jobs. enableDnsSupport, on by default, activates the Route 53 Resolver at the VPC CIDR base plus two and at the link-local address 169.254.169.253; turning it off means instances get no resolver at all unless a DHCP option set names one. enableDnsHostnames, off by default for a VPC you create, is what gives instances public DNS names. Private DNS on an interface endpoint requires both to be true, which is why an endpoint that is healthy and correctly secured can still be bypassed entirely by an SDK resolving the public name.

The resolver is also the hinge for hybrid naming. A Route 53 private hosted zone associated with a VPC shadows a name that may also exist publicly. Crossing the boundary is automatic in neither direction: on-premises clients querying a private zone need a Route 53 Resolver inbound endpoint, and VPC instances querying on-premises names need an outbound endpoint with forwarding rules. A zone that was never associated with the querying VPC has a signature worth recognising: connectivity tests by IP all succeed while every hostname fails, which sends people hunting through routes and security groups for a problem that is not there.

Two operational details. A DHCP option set cannot be modified after creation; you create a new one and associate it, and instances pick the change up only on lease renewal, so a fleet converges over hours. And the resolver enforces a hard per-ENI limit of 1024 query packets per second that nothing raises. A dense container host, especially with a search-domain suffix causing several lookups per name, hits it and sees intermittent resolution failures with no error anywhere in the network layer. Node-local DNS caching is the fix.

Peering, Transit Gateway, and what non-transitivity forbids

A peering connection joins exactly two VPCs, in any account and any region. The mesh arithmetic and the case for a hub are worked through in VPC peering; the AWS specifics are the constraints.

The CIDR ranges must not overlap, and no amount of routing cleverness works around it. Routes must be added on both sides — a request accepted with no route entries produces a connection that exists and carries nothing. And peering is non-transitive: a peer of your peer is unreachable, because the middle VPC forwards nothing between its two connections. That is deliberate, not a gap.

The rule people trip over is no edge-to-edge routing. A VPC cannot use its peer's internet gateway, NAT gateway, gateway endpoint, or VPN and Direct Connect connections. The centralised-egress design that seems obvious — one VPC owns the NAT gateways and everyone else peers to it — simply does not work, and that pattern requires a Transit Gateway instead (AWS Transit Gateway). Inter-region peering adds two more restrictions: no security group referencing, and jumbo frames do not survive the crossing, so an MTU that worked within a region drops to 1500 bytes.

A third option involves no connection at all. Sharing subnets through AWS Resource Access Manager lets several accounts place resources into one VPC. Participants manage only their own resources and cannot modify route tables, NACLs or the subnets, and the owner cannot delete a subnet still holding participant resources. It removes the connectivity problem by removing the second network, at the cost of a shared blast radius.

Flow logs, Reachability Analyzer, and what you cannot see

There is no span port in a VPC. Flow logs are the only record of who talked to whom, enabled at three scopes — a single ENI, a subnet, or the whole VPC — publishing to CloudWatch Logs, S3, or Data Firehose. The aggregation interval is one or ten minutes, so records arrive minutes after the traffic: an investigation tool, not an alerting one.

A default record identifies the two endpoints by address and port, names the protocol, counts packets and bytes over the interval it covers, and ends in an ACCEPT or REJECT verdict. A custom format earns its keep for two fields: pkt-srcaddr and pkt-dstaddr report the original addresses before a NAT gateway or load balancer rewrote them, and without them every flow behind a NAT gateway looks like it came from the NAT gateway. log-status carries NODATA when an interface saw no traffic in the window and SKIPDATA when records were dropped internally — silence in a flow log is not proof of silence on the wire.

Several categories are never logged at all: traffic to and from the instance metadata service at 169.254.169.254, the Amazon time service, DHCP, the Amazon-provided DNS resolver, the reserved addresses of the VPC router, and traffic between an endpoint ENI and a Network Load Balancer ENI.

For the question can this reach that, VPC Reachability Analyzer answers from configuration rather than from traffic: it evaluates routes, security groups and NACLs between two endpoints and names the specific component blocking the path, which beats bisecting rules by hand. Network Access Analyzer runs the inverse query, finding paths that exist but should not.

Failure modes that trace back to one specific object

The subnet ran out of addresses. An autoscaling event fails, and it surfaces as an application that will not grow past a certain size rather than as a network alert. Subnets cannot be resized. Alarm on available IPs per subnet per AZ, in absolute numbers rather than percentages.

One NAT gateway for three zones. Works perfectly, costs cross-AZ transfer on all egress, and converts a single-zone impairment into a full outage for outbound traffic.

The subnet was using the main route table. A route added for one workload reaches every subnet that was never explicitly associated. Explicitly associate every subnet, and keep the main table empty of anything but the local route.

A NACL permits the request and drops the reply. The stateless layer evaluates each direction independently, so an outbound allow-list with no matching ephemeral inbound range produces a connection that opens and then hangs — for every workload in the subnet, including teams that were not part of the change.

The route points at something that was deleted. Blackhole routes discard traffic in silence. Removing an attachment does not remove the routes that referenced it.

A VPC is a small set of objects with unforgiving defaults. The CIDR is the one decision you cannot revise, and each subnet gives you five fewer addresses than the mask suggests. Whether a subnet is public is a property of its route table and nothing else, so associate every subnet explicitly and leave the main table bare. Put intent in security groups referencing other security groups, keep NACLs to a few absolute statements, and remember that peering will not lend you a peer's gateway. Turn on flow logs with pkt-srcaddr early, because a rule can only be tightened by someone who can show what currently travels through it.