AWS Networking Deep Dive: VPCs, Transit Gateway, and PrivateLink
AWS networking is the one infrastructure decision that is genuinely hard to change after the fact. You can resize EC2 instances, swap RDS engines, and refactor IAM policies with manageable effort. Changing your VPC CIDR range after you have deployed services, connected to on-premises networks, and peered with a dozen other VPCs is not impossible — it is painful enough that most teams just live with the suboptimal choice rather than fix it.
This post covers the decisions that matter most in AWS network design and the trade-offs that determine which connectivity option to reach for. It is organized around the questions that come up repeatedly: how to size a CIDR range, how to structure subnets, when VPC peering is sufficient and when Transit Gateway is warranted, when PrivateLink is the right abstraction, and how to get DNS to work correctly across all of it.
VPC Fundamentals
A VPC (Virtual Private Cloud) is a logically isolated network within AWS. It spans all Availability Zones in a region. Everything inside it is private by default — no inbound or outbound internet access unless you explicitly add it.
The major components:
VPC (10.0.0.0/16)
├── Availability Zone us-east-1a
│ ├── Public Subnet (10.0.1.0/24) ← Internet-routable via IGW
│ ├── Private Subnet (10.0.11.0/24) ← Outbound via NAT GW; inbound only via LB
│ └── Data Subnet (10.0.21.0/24) ← No internet access; intra-VPC only
├── Availability Zone us-east-1b
│ ├── Public Subnet (10.0.2.0/24)
│ ├── Private Subnet (10.0.12.0/24)
│ └── Data Subnet (10.0.22.0/24)
└── Availability Zone us-east-1c
├── Public Subnet (10.0.3.0/24)
├── Private Subnet (10.0.13.0/24)
└── Data Subnet (10.0.23.0/24)
Gateways:
Internet Gateway (IGW) — attached to VPC; enables public subnet internet access
NAT Gateway(s) — in public subnets; enables private subnet outbound access
Virtual Private Gateway — for VPN/Direct Connect
Route Tables:
Public RT: 0.0.0.0/0 → IGW
Private RT: 0.0.0.0/0 → NAT GW (per-AZ NAT GW for HA)
Data RT: local only (no default route)
CIDR Planning
The single most common mistake in AWS networking is choosing a CIDR range that creates problems later. Problems take two forms: overlapping ranges that prevent peering or VPN connectivity, and ranges that are too small and cannot accommodate growth.
The overlap problem
RFC 1918 defines three private ranges:
10.0.0.0/8— 16 million addresses172.16.0.0/12— 1 million addresses192.168.0.0/16— 65,000 addresses
VPC peering, Transit Gateway, and VPN/Direct Connect all require non-overlapping CIDR ranges across every connected network. If your VPC uses 10.0.0.0/16 and your on-premises network also uses 10.0.0.0/16, you can never connect them without NAT — and NAT at that scale defeats the purpose of private connectivity.
The discipline: assign non-overlapping /16 blocks from the 10.x.0.0/16 range, one per VPC, allocated from a central registry (AWS IPAM or a spreadsheet that someone actually maintains). Reserve ranges for on-premises networks so you never assign a VPC CIDR that collides with a future Direct Connect.
A reasonable allocation scheme for a growing AWS organization:
10.0.0.0/8 ← entire space
├── 10.0.0.0/12 ← AWS production accounts
│ ├── 10.0.0.0/16 prod-us-east-1 (app VPC)
│ ├── 10.1.0.0/16 prod-us-east-1 (data VPC)
│ ├── 10.2.0.0/16 prod-us-west-2 (app VPC)
│ └── ...
├── 10.16.0.0/12 ← AWS non-prod accounts
│ ├── 10.16.0.0/16 staging-us-east-1
│ ├── 10.17.0.0/16 dev-us-east-1
│ └── ...
├── 10.32.0.0/12 ← on-premises networks
│ ├── 10.32.0.0/16 datacenter-nyc
│ └── 10.33.0.0/16 datacenter-lax
└── 10.48.0.0/12 ← reserved for future
Start with /16 per VPC. That gives you 65,534 usable addresses — more than enough for any single VPC workload. The temptation to use /24 per VPC to “save address space” causes pain when services like EKS consume hundreds of IPs per node for pod networking.
The five-address rule
AWS reserves five IP addresses in every subnet: the network address, VPC router, DNS resolver, future use, and broadcast. A /28 subnet (16 addresses) has 11 usable. A /24 (256 addresses) has 251 usable. Account for this when sizing subnets, especially for services that consume many IPs.
EKS and IP exhaustion
EKS with the default VPC CNI assigns one VPC IP per pod. A node with 30 pods uses 31 IPs (30 pods + 1 for the node). A cluster with 100 nodes running 30 pods each uses 3,100 IPs. A /24 subnet holds 251 IPs — a single EKS node group can exhaust a /24 in minutes.
For EKS, use /19 or larger private subnets, or enable VPC CNI prefix delegation (which assigns /28 prefixes to nodes rather than individual IPs, multiplying per-node capacity by 16).
Secondary CIDRs
If you discover a CIDR is too small after deployment, AWS lets you add secondary CIDR blocks to an existing VPC (up to five total). This is useful for adding EKS pod subnets that use a non-overlapping range from the main VPC CIDR:
|
|
The 100.64.0.0/10 range (Carrier-Grade NAT space) is a common choice for EKS pod subnets because it is not routable on the public internet and is unlikely to conflict with on-premises RFC 1918 ranges.
Subnet Layout
Tier model
Three subnet tiers per AZ covers most architectures:
Public subnets (0.0.0.0/0 → IGW in the route table): These hold resources that must be directly reachable from the internet — NAT Gateways, Application Load Balancers, bastion hosts, and occasionally ECS/EC2 instances serving public traffic. Do not put databases, internal APIs, or EKS nodes here.
Private subnets (0.0.0.0/0 → NAT GW): Application servers, EKS nodes, Lambda functions, ECS tasks. Outbound internet access via NAT Gateway; no inbound from the internet directly. Inbound from the internet only via a load balancer in the public subnet.
Data/isolated subnets (no default route, local only): RDS, ElastiCache, Kafka, and other stateful services that should never make outbound internet calls. Only reachable from within the VPC. Not having a default route here is a hard guardrail — misconfigured application code cannot accidentally call out to the internet from a database subnet.
Sizing subnets
Each subnet lives in exactly one AZ. For HA, create the same tier in each AZ you want to use (typically 2–3). Size each subnet large enough to accommodate the maximum number of resources you expect in that tier, with room for autoscaling and IP churn.
| Tier | Typical size | Reasoning |
|---|---|---|
| Public | /27 or /28 | NAT Gateways and LBs; low count |
| Private (general) | /20 or /19 | App servers, containers; high density |
| Private (EKS pods) | /19 or /18 | IP-hungry; see EKS note above |
| Data | /24 or /23 | DBs, cache; moderate count |
| TGW attachments | /28 | Reserved exclusively for TGW ENIs |
Transit Gateway attachment subnets
When you attach a VPC to Transit Gateway, TGW creates one ENI per AZ in a designated subnet. These subnets should be dedicated — no workload resources — and sized at /28 (11 usable IPs is more than enough for TGW ENIs). Use secondary CIDR space for them so they do not consume your main CIDR allocation:
|
|
Security Groups vs NACLs
Both are traffic filters, but they operate at different levels and have fundamentally different behavioral models.
Security groups
Security groups are stateful and operate on resources (ENIs — Elastic Network Interfaces attached to EC2, RDS, Lambda, ECS, etc.). Stateful means return traffic is automatically allowed — you allow port 443 inbound and responses on ephemeral ports go out without an explicit outbound rule. Security groups support allow rules only; there is no deny rule.
Security group: sg-web
Inbound:
TCP 443 from 0.0.0.0/0 (HTTPS from internet)
TCP 80 from 0.0.0.0/0 (HTTP for redirect)
Outbound:
All traffic 0.0.0.0/0 (default; restrict if needed)
Security group: sg-app
Inbound:
TCP 8080 from sg-web (reference SG by ID, not CIDR)
Outbound:
TCP 5432 from sg-db
TCP 443 0.0.0.0/0 (for AWS API calls)
Security group: sg-db
Inbound:
TCP 5432 from sg-app
Outbound:
(none required — return traffic handled by stateful tracking)
Referencing security groups by ID (rather than CIDR) in inbound rules is the AWS-native pattern for service-to-service access control. It works across Availability Zones within a VPC without managing IP ranges.
NACLs
NACLs (Network Access Control Lists) are stateless and operate on subnets. Stateless means you must explicitly allow both inbound and outbound traffic for any connection, including return traffic on ephemeral ports (1024–65535). NACLs support both allow and deny rules, evaluated in numerical order (lowest number wins).
The stateless nature makes NACLs error-prone for most use cases. A common mistake is adding an inbound allow rule for port 443 without adding an outbound allow rule for ephemeral ports — connections appear to work from the application but time out unpredictably because response packets are dropped at the NACL.
In practice: use security groups for almost everything. NACLs have two legitimate use cases:
Blocking specific source IPs at subnet scope: NACLs can explicitly deny traffic from a known-bad IP range across an entire subnet. Security groups cannot deny — you can only decline to allow. If you are blocking a botnet or an abusive IP block, a NACL deny rule is the right tool.
Defense in depth for isolated subnets: A NACL on a data subnet that denies all inbound except from the private subnet CIDR provides a second layer of isolation if a security group is misconfigured. It does not replace security groups but provides a harder subnet-level boundary.
VPC Peering
VPC peering creates a direct network route between two VPCs. Traffic between them does not traverse the internet, does not go through NAT, and does not require a gateway. It works across accounts and regions.
|
|
After creating the connection, add routes in both VPCs:
|
|
The non-transitive limit
VPC peering is point-to-point and non-transitive. If VPC A peers with VPC B, and VPC B peers with VPC C, traffic from A cannot reach C through B. Each pair that needs to communicate requires its own peering connection.
For N VPCs that all need to communicate, peering requires N×(N-1)/2 connections:
3 VPCs → 3 peering connections
5 VPCs → 10 peering connections
10 VPCs → 45 peering connections
The route table management grows at the same rate. At 5+ VPCs, this becomes operationally unwieldy. The per-VPC limit is 125 active peering connections.
Use peering when: you have two or three VPCs with simple, stable connectivity requirements. Same-region peering has no data transfer charge (only standard EC2 data transfer rates). Cross-region peering costs $0.01/GB in each direction.
Transit Gateway
Transit Gateway is a regional router that acts as a hub for VPC-to-VPC and VPC-to-on-premises connectivity. Instead of managing a mesh of peering connections, you attach each VPC to Transit Gateway once, and TGW handles routing between them.
┌─────────────────────┐
VPC-A ─────────── │ │ ─────── VPN (on-premises)
VPC-B ─────────── │ Transit Gateway │ ─────── Direct Connect
VPC-C ─────────── │ (regional hub) │ ─────── TGW peering (other region)
VPC-D ─────────── │ │
└─────────────────────┘
TGW route tables
TGW has its own route tables, separate from VPC route tables. By default, all attached VPCs and VPNs share a single route table and can route to each other. For isolation, create separate TGW route tables and use attachments and propagations to control which networks can reach which:
TGW Route Table: production
10.0.0.0/16 → VPC-prod-app
10.1.0.0/16 → VPC-prod-data
10.32.0.0/16 → Direct Connect (on-premises)
TGW Route Table: development
10.16.0.0/16 → VPC-dev
10.17.0.0/16 → VPC-staging
# No on-premises routes — dev cannot reach production datacenter
This is the standard pattern for environment isolation: production and development VPCs both attach to TGW but use separate route tables that do not share routes.
Terraform for TGW
|
|
In each VPC, add a route to TGW for any CIDR that TGW should route:
|
|
TGW cost
TGW charges per attachment-hour ($0.05/hr per attachment) plus per GB of data processed ($0.02/GB). At 20 VPCs attached 24/7, that is $720/month in attachment fees before any data transfer. This is not a reason to avoid TGW at scale — it is much less than the operational cost of managing a peering mesh — but it is a reason not to attach VPCs that do not need connectivity.
Use Transit Gateway when: you have more than 5 VPCs that need mutual connectivity, you need VPN or Direct Connect termination shared across multiple VPCs, or you need environment-level isolation via separate TGW route tables.
PrivateLink
PrivateLink is a completely different connectivity model from peering and TGW. Rather than connecting networks, PrivateLink exposes a specific service endpoint in one VPC as a private ENI in another VPC — without any network-level routing between the two VPCs.
Consumer VPC Provider VPC
┌──────────────────────┐ ┌──────────────────────┐
│ │ │ │
│ [App] → [Interface │◄──────►│ [NLB] → [Service] │
│ Endpoint] │ AWS │ │
│ (private ENI │ fabric │ │
│ 10.0.1.55) │ │ │
└──────────────────────┘ └──────────────────────┘
The Interface Endpoint is an ENI with a private IP in the consumer VPC’s subnet. Traffic from the consumer to that IP goes directly to the service in the provider VPC via AWS’s internal fabric — not over the internet, not through any routing path between the VPCs. The provider’s VPC CIDR is irrelevant; even overlapping CIDRs work with PrivateLink.
VPC Interface Endpoints for AWS services
Every AWS service that supports VPC Interface Endpoints (S3, STS, EC2, ECR, Secrets Manager, KMS, SSM, CloudWatch, and dozens more) can be consumed via PrivateLink so that traffic stays within the AWS network rather than going through NAT Gateway to the public endpoint:
|
|
Gateway endpoints (S3 and DynamoDB only) are free and work by injecting routes into your route table. Interface endpoints cost $0.01/hr per AZ plus $0.01/GB. The math: a NAT Gateway costs $0.045/hr + $0.045/GB; routing S3 or Secrets Manager traffic through Interface Endpoints instead of NAT Gateway saves money at meaningful data transfer volumes.
PrivateLink for internal services
The PrivateLink model for exposing your own services to other VPCs or accounts: put a Network Load Balancer in front of your service, register it as a VPC Endpoint Service, and consumers create Interface Endpoints that point to it.
|
|
This is the zero-trust model for service-to-service communication across VPCs: the consumer can reach the payments API endpoint (and nothing else in the provider VPC), the provider VPC does not need any security group rules allowing inbound from the consumer, and the connection works even if the two VPCs have overlapping CIDRs.
Use PrivateLink when: you want to expose a specific service across account or VPC boundaries without granting any network-level access; the two VPCs have overlapping CIDRs; or you want to sell a service to external customers while keeping it private.
NAT Gateway
Private subnets need a NAT Gateway for outbound internet access (downloading packages, calling external APIs, etc.). NAT Gateways are AZ-specific and not shared across AZs — an instance in us-east-1b must use the NAT Gateway in us-east-1b.
For high availability, create one NAT Gateway per AZ and update each AZ’s private route table to use its local NAT Gateway:
|
|
NAT Gateway costs $0.045/hr (~$32/month) per gateway plus $0.045/GB of data processed. Three AZs with three NAT Gateways is ~$96/month before data transfer. For a development environment, one NAT Gateway shared across AZs is acceptable (cross-AZ traffic for the route costs $0.01/GB, but dev environments have low traffic).
Reducing NAT Gateway costs with VPC endpoints
High-volume AWS API traffic (S3, DynamoDB, ECR image pulls, CloudWatch logs, SSM agent communication) that routes through NAT Gateway is expensive. Replace it with VPC endpoints:
| Service | Endpoint type | Cost |
|---|---|---|
| S3 | Gateway | Free |
| DynamoDB | Gateway | Free |
| ECR API + Docker | Interface | $0.01/hr + $0.01/GB |
| CloudWatch Logs | Interface | $0.01/hr + $0.01/GB |
| SSM | Interface | $0.01/hr + $0.01/GB |
| Secrets Manager | Interface | $0.01/hr + $0.01/GB |
For an EKS cluster that pulls ECR images frequently, the Interface Endpoint for ECR pays for itself quickly by eliminating NAT Gateway data processing charges on image pulls.
Route 53 Resolver and Private DNS
Private hosted zones
A private hosted zone is a Route 53 DNS zone that resolves only within the VPCs you associate it with. It enables internal service discovery without public DNS exposure:
|
|
Resources in the associated VPC resolve payments.internal.acme.com to the load balancer’s private IP. Resources outside the VPC get NXDOMAIN. If you associate the same private zone with multiple VPCs, all of them resolve the internal names — useful when splitting an application across a service VPC and a consumer VPC connected via TGW.
Split-horizon DNS
Split-horizon (split-view) uses the same domain name for different answers internally vs externally. A record for api.acme.com in the public hosted zone points to the ALB’s public IP; the same record in the private hosted zone points to the ALB’s private IP. Internal services hit the private IP directly without hairpinning through the internet.
Route 53 evaluates private hosted zones before the public zone when a query comes from an associated VPC. No configuration needed — the private zone “shadows” the public zone automatically for associated VPCs.
Hybrid DNS: Resolver endpoints
When VPNs or Direct Connect connect AWS to on-premises networks, DNS resolution needs to work in both directions:
- On-premises servers querying AWS private hosted zones
- AWS services querying on-premises DNS servers
Route 53 Resolver endpoints solve this:
Inbound Resolver Endpoint: an ENI in your VPC that accepts DNS queries from outside (on-premises DNS servers forward queries for *.internal.acme.com to this IP, and Resolver answers using the private hosted zone).
Outbound Resolver Endpoint: an ENI in your VPC that forwards DNS queries to external resolvers based on domain rules (queries for *.corp.example.com are forwarded to the on-premises DNS server at 10.32.1.10).
|
|
Decision Framework
Need connectivity between two VPCs?
├── Do they have overlapping CIDRs?
│ └── YES → PrivateLink (service-level access only)
│
├── Is it one specific service, not full network access?
│ └── YES → PrivateLink
│
├── Is it temporary or between fewer than 3 VPCs?
│ └── YES → VPC Peering
│
└── Is it many VPCs, or do you need on-premises connectivity too?
└── YES → Transit Gateway
├── Need environment isolation (prod ≠ dev)?
│ └── Separate TGW route tables
└── Shared services VPC (DNS, monitoring)?
└── Share routes to it from all TGW route tables
In practice, most organizations end up with all three: Transit Gateway as the backbone for VPC-to-VPC and on-premises traffic, PrivateLink for AWS service endpoints and select cross-account service exposure, and VPC peering for occasional simple two-VPC cases.
Common Pitfalls
Not reserving TGW attachment subnets: When you add TGW later (most teams do not start with it), you need subnets dedicated to TGW ENIs. If your CIDR is already fully allocated to workload subnets, you cannot create TGW attachment subnets without restructuring — hence the recommendation to allocate /28 TGW subnets from secondary CIDR space at VPC creation.
Cross-AZ data transfer charges: Data that crosses AZ boundaries costs $0.01/GB in each direction. This adds up when every request from an app tier instance in 1a hits a database in 1b. Design services to prefer same-AZ communication: EKS topology spread constraints, RDS Aurora reader instances in multiple AZs, ElastiCache cluster mode.
NAT Gateway as an implicit cost amplifier: Every outbound byte through NAT Gateway costs $0.045/GB. ECS tasks pulling ECR images on every deploy, Lambda functions downloading large packages on cold start, pods pulling from S3 — all of this runs up the NAT bill. VPC endpoints for ECR, S3, and CloudWatch eliminate this at modest endpoint cost.
Security group rule accumulation: Security groups are append-only in practice — teams add rules, rarely remove them. A security group with 50 inbound rules becomes impossible to audit. Enforce a rule: if you cannot describe in one sentence why a security group rule exists, it should not exist. Reference security groups by ID rather than CIDR wherever possible; CIDR-based rules require manual updates when IP ranges change.
Forgetting private DNS on Interface Endpoints: private_dns_enabled = true on a VPC Interface Endpoint makes the AWS service’s public hostname (e.g., secretsmanager.us-east-1.amazonaws.com) resolve to the private endpoint IP inside the VPC. Without it, your code needs to explicitly target the private endpoint URL, which requires code changes. Enable private DNS and no code changes are needed.
CIDR range too small for future peering: A VPC with a /24 CIDR cannot accommodate a secondary CIDR for TGW attachment subnets without using addresses from the primary range. A /16 VPC with disciplined subnet allocation leaves room to grow.
Comments