Size the VPC CIDR for the account’s lifetime, not the first workload
The single most common VPC mistake is picking a CIDR block sized for what you need today. A /24 (256 addresses) feels generous when you’re launching a dozen EC2 instances. Eighteen months later you’re adding a second AZ, a third tier, an EKS cluster that wants a /20 worth of pod IPs, and you discover the block is exhausted and every neighboring range in your IPAM plan is already claimed by another account.
A VPC’s IPv4 CIDR must sit between a /16 and a /28 netmask — /16 is the largest block AWS will accept, and you cannot resize a CIDR after creation (you can only associate additional, non-overlapping secondary blocks) (VPC CIDR blocks, AWS docs). That asymmetry is the whole argument: there is no “grow the /24 later” option.
Default to a /16 per VPC (65,536 addresses) unless you have a specific, documented reason not to. That gives you room for:
- Multiple subnet tiers (public, private, isolated) across 3 AZs
- A second generation of subnets when you need to re-tier without touching the first
- IP-hungry workloads — EKS in particular, where the Amazon VPC CNI assigns each pod a private IPv4 or IPv6 address from your VPC (Amazon VPC CNI, EKS docs), so pod count is an address-planning input, not just a scheduling one
A /16 costs nothing extra to provision. The constraint isn’t AWS billing, it’s not overlapping with anything you will ever need to connect to — on-premises networks, other VPCs you’ll peer with, other accounts in the same Transit Gateway. That’s the actual planning discipline: treat your entire organization’s address space, across every AWS account and every on-prem site, as one plan before you provision a single VPC. Keep a spreadsheet or an IPAM tool (Amazon VPC IPAM is the AWS-native option; confirm its current cross-account and organization-wide allocation capabilities against AWS documentation before you design a process around them) as the single source of truth, and allocate VPC CIDRs from it the way a network engineer allocates subnets from a corporate /8.
The 5 addresses AWS reserves in every subnet
This is the detail that breaks people’s mental math. In a standard IPv4 subnet, you’d expect 2^(32-prefix) - 2 usable addresses (subtracting network and broadcast). AWS subtracts 5, not 2 — specifically “the first four IP addresses and the last IP address in each subnet CIDR block” (Subnet CIDR blocks, AWS docs):
| Offset | Reserved for |
|---|---|
.0 | Network address |
.1 | VPC router |
.2 | DNS (Amazon-provided, maps to the VPC+2 resolver) |
.3 | Reserved for future use |
| Last address | Broadcast (unused in VPC networking, but still reserved) |
So a /24 subnet (256 addresses) gives you 251 usable IPs, not 254. A /28 (16 addresses) gives you 11, not 14. The one documented exception is address space you bring to AWS yourself: with BYOIP you can use the whole range, including the first and last address.
This matters enormously at the small end, because a subnet’s IPv4 CIDR is itself constrained to between a /28 and a /16 netmask — a /28 is the smallest subnet AWS will create, and 11 usable addresses is the floor, not a figure you can work below.
Build your sizing table with the 5-address tax baked in from the start:
| CIDR | Total addresses | Usable (AWS) |
|---|---|---|
| /28 | 16 | 11 |
| /27 | 32 | 27 |
| /26 | 64 | 59 |
| /24 | 256 | 251 |
| /20 | 4,096 | 4,091 |
One common misconception worth retiring here: a VPC-attached Lambda function does not consume an IP per concurrent execution. Lambda creates a shared Hyperplane ENI per unique subnet-and-security-group combination and reuses it across functions with the same combination, scaling the ENI count by traffic rather than by concurrency (Hyperplane ENIs, Lambda docs). Size the subnet for the services that do consume an address each — instances, tasks, pods, interface endpoints, RDS interfaces.
Tiering: public, private, and isolated are different trust boundaries
Don’t default every subnet to “public.” Three tiers cover almost every real architecture:
- Public — has a route to an Internet Gateway. Reserve this for load balancers, NAT gateways, and bastion/jump infrastructure. Nothing that holds data belongs here.
- Private (with egress) — routes outbound through a NAT gateway or NAT instance. This is where application and compute tiers live — they can reach out for package updates, API calls, licensing checks, but nothing can initiate inbound from the internet.
- Isolated (no egress) — no route to the internet at all, in either direction. Databases, internal-only services, anything subject to a compliance control that says “no internet path, full stop.” Reach AWS services via VPC endpoints — gateway endpoints exist only for Amazon S3 and DynamoDB and are driven by route tables, while everything else uses an interface endpoint backed by PrivateLink — rather than opening a NAT path just to hit an AWS API.
Size each tier independently and give each its own CIDR block per AZ — don’t try to carve one subnet into multiple roles. A reasonable default inside a /16 is three /20s per AZ (public, private, isolated), which uses 36,864 of the 65,536 addresses and leaves about 44 per cent free for future tiers or services that want their own subnet (EKS, RDS subnet groups, Transit Gateway attachments).
Spread subnets across AZs — but mind the quota math
Each tier needs a subnet in each AZ you operate in, and the AZ failure domain only protects you if resources are actually spread across subnets — an ASG spanning three AZ subnets is resilient to an AZ outage; one in a single subnet is not. Use 3 AZs for anything that needs to survive an AZ failure; 2 is the minimum for most Multi-AZ managed services — an RDS DB subnet group must have at least one subnet in at least two Availability Zones, and AWS’s own best-practice guidance is a subnet in every AZ in the Region so RDS can place a replacement standby wherever it needs to (DB subnet groups, RDS docs) — but 3 gives you a cleaner story when one AZ is degraded and you don’t want to run at 50% capacity on the surviving half.
Route tables are per-subnet, not per-AZ, so your AZ count multiplies your subnet count, which multiplies your route table count if each tier needs distinct routing (and isolated tiers usually do — no 0.0.0.0/0 route at all).
Route tables: the explicit model that trips people up
A VPC’s “main” route table is the default for any subnet not explicitly associated with another. People leave critical subnets on the main route table by accident and then can’t explain why a new private subnet suddenly has unexpected routing behavior after someone edited the main table for an unrelated reason. Associate every subnet explicitly, and never treat the main route table as anything other than a catch-all you deliberately keep empty or minimal.
Private subnet route table (example):
10.0.0.0/16 local
0.0.0.0/0 nat-0123456789abcdef0 (NAT gateway, same AZ)
pl-xxxxxxxx (S3) vpce-0123456789abcdef0 (gateway endpoint)
With zonal NAT gateways, keep NAT routes AZ-local. Routing a private subnet’s egress through a zonal NAT gateway in a different AZ works, but it adds a cross-AZ data transfer charge to every byte of egress traffic and introduces a dependency that defeats the purpose of AZ isolation — AWS states plainly that if resources in multiple AZs share one NAT gateway and that gateway’s AZ is down, the resources in the other AZs lose internet access. A regional NAT gateway removes this particular routing discipline, as discussed below.
NAT gateway placement: the cost/availability trade-off, stated plainly
A NAT gateway now has two availability modes, and the older “it’s AZ-scoped, deal with it” framing is out of date. A zonal NAT gateway — the long-standing default — “is created in a specific Availability Zone and implemented with redundancy in that zone,” and AWS is explicit about the consequence: if resources in several AZs share one NAT gateway and that gateway’s AZ goes down, the resources in the other AZs lose internet access (NAT gateway basics, AWS docs). A regional NAT gateway instead “automatically expands across Availability Zones based on your workload presence,” needs no public subnet, and is addressed by a single NAT ID from route tables in every AZ (Regional NAT gateways, AWS docs).
| Pattern | Availability | Operational shape |
|---|---|---|
| Regional NAT gateway | Expands and contracts with your workload footprint across AZs; high availability is the default | One NAT ID, one route entry reusable across AZs, no public subnet to host it. Does not support private NAT |
| One zonal NAT gateway per AZ | Survives an AZ failure — other AZs keep egress | N gateways, N public subnets, N AZ-local routes to maintain as you add AZs |
| Single zonal NAT gateway | Total egress loss for every AZ routed to it if that AZ fails | Cheapest to run, concentrates risk |
Two caveats on the regional mode that matter before you adopt it: it does not offer private NAT (use zonal mode if you need a private NAT gateway), it is not available in constrained Availability Zones, and expansion into a newly-populated AZ can take up to 60 minutes — until it completes, that AZ’s traffic is processed cross-zone by an existing zone.
Cost shape is unchanged in kind: NAT gateways bill an hourly charge plus a per-GB data processing charge, so a per-AZ zonal fleet multiplies the hourly component by AZ count. Check current rates for both availability modes on the VPC pricing page before modelling the difference. For a dev/sandbox VPC with no SLA, a single zonal NAT gateway (or even a NAT instance, which is cheaper at low throughput but you now own patching and failover) is still a legitimate way to cut cost — just document that you made the trade-off on purpose, so nobody “fixes” it into a redundant production pattern later without re-evaluating the cost.
Throughput is rarely the binding constraint: a NAT gateway supports 5 Gbps and automatically scales up to 100 Gbps, and processes one million packets per second scaling to ten million. Beyond that it drops packets, and the documented remedy is to split resources across subnets with a gateway each.
VPC Peering vs Transit Gateway: pick based on topology, not features
| VPC Peering | Transit Gateway | |
|---|---|---|
| Topology | Point-to-point, non-transitive | Hub-and-spoke, transitive across attachments |
| Scaling to N VPCs | N(N-1)/2 connections — becomes unmanageable past ~5-10 VPCs | Linear — one attachment per VPC |
| Cross-region | Supported, no extra device | Supported via peering between TGWs |
| Cost | No charge to create the connection; data transfer charges apply across AZs and Regions (same-AZ peering traffic is free) | Charged hourly for each attachment, plus the traffic processed on the gateway |
| Route table complexity | Simple per-pair, but no transitive routing (A-B and B-C doesn’t give you A-C) | Centralized, but you must manage TGW route tables deliberately or you’ll bridge segments you meant to keep isolated |
Two peering limitations are worth stating as hard facts rather than preferences, because both are documented and neither has a routing-layer workaround (VPC peering basics and limitations, AWS docs). First, transitive peering is unsupported: if A is peered to B and A is peered to C, you cannot route B-to-C traffic through A. Second, edge-to-edge routing through a gateway or private connection is unsupported: resources in a peered VPC cannot use the other VPC’s internet gateway, NAT device, VPN connection, Direct Connect connection, or S3 gateway endpoint. That second one is the trap — a “shared egress VPC” built on peering does not work, which is a large part of why centralised egress designs use Transit Gateway. Peering also cannot be created at all between VPCs with overlapping IPv4 or IPv6 CIDRs, even if you only intend to use the non-overlapping parts.
Transit Gateway, by contrast, is a transit hub whose attachments route to one another through its route tables, and transit gateways in different Regions can be joined by inter-Region peering attachments (Transit Gateway, AWS docs).
If you have 2-3 VPCs that need to talk and the topology won’t grow, peering is simpler and cheaper. The moment you’re planning for more than a handful of VPCs, or you know a hub-and-spoke model with shared services (logging, security tooling, egress) is coming, start with Transit Gateway — migrating from a mesh of peering connections to TGW later is a real project, not a quick swap.
IPv6: dual-stack is the pragmatic default now
A VPC can carry an IPv4 CIDR and one or more IPv6 CIDRs, and AWS also supports IPv6-only subnets for workloads that need no IPv4. The practical notes:
- An Amazon-provided IPv6 CIDR block is assigned as a /56 — you cannot choose the range — and you carve subnets from it, with permitted subnet netmasks between
/44and/64in increments of/4;/64is the conventional subnet size (see the VPC CIDR block and subnet sizing references cited above). There’s no address-exhaustion planning problem the way there is with IPv4, which removes an entire category of re-addressing risk. - The five-address reservation applies to IPv6 subnets too: the first four and the last address of each subnet CIDR are reserved.
- Security groups and NACLs need explicit IPv6 rules — they are not automatically dual-stack just because the subnet is. Audit existing security groups when you add IPv6; an IPv4-only allow rule set gives you a false sense of coverage on a dual-stack subnet.
- Egress-only internet gateways are the IPv6 equivalent of a NAT gateway for private subnets — “a horizontally scaled, redundant, and highly available VPC component” that allows outbound IPv6 and prevents the internet from initiating inbound, and there is no charge for the gateway itself, only the usual data transfer (Egress-only internet gateways, AWS docs). That absence of a per-GB processing charge is a real cost advantage over NAT gateway for IPv6-heavy egress.
Don’t go IPv6-only yet unless every downstream dependency (on-prem, SaaS integrations, monitoring agents) is confirmed to support it — verify current compatibility against AWS and your vendors’ documentation before committing a production workload.
The discipline that prevents re-addressing pain
Every VPC re-addressing project exists because two networks that needed to connect discovered they shared overlapping CIDR space. It happens between regions in the same account, between accounts in the same Organization, and most often between a newly-provisioned AWS environment and an on-premises network nobody checked against. The fix is unglamorous and nearly free: maintain one authoritative CIDR allocation registry across your entire organization — every AWS account, every VPC, every on-prem site — before provisioning, use AWS IPAM or even a shared spreadsheet enforced by process, and treat a CIDR collision caught at planning time as a non-event, versus the multi-week migration it becomes once workloads are live on both sides.