LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

AWS Networking Deep Dive: VPCs, Transit Gateway, and PrivateLink

awsnetworkingclouddevopssecurityinfrastructure-as-code

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 addresses
  • 172.16.0.0/12 — 1 million addresses
  • 192.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:

1
2
3
aws ec2 associate-vpc-cidr-block \
  --vpc-id vpc-0abc123 \
  --cidr-block 100.64.0.0/16   # RFC 6598 shared address space — not routable on internet

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:

1
2
3
4
5
6
7
8
9
resource "aws_subnet" "tgw_attachment" {
  for_each = toset(["us-east-1a", "us-east-1b", "us-east-1c"])

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet("100.64.0.0/16", 12, index(["us-east-1a", "us-east-1b", "us-east-1c"], each.key))
  availability_zone = each.key

  tags = { Name = "tgw-attach-${each.key}" }
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Create peering request (from requester account)
aws ec2 create-vpc-peering-connection \
  --vpc-id vpc-0abc123 \
  --peer-vpc-id vpc-0def456 \
  --peer-owner-id 987654321098 \
  --peer-region us-west-2

# Accept in the peer account
aws ec2 accept-vpc-peering-connection \
  --vpc-peering-connection-id pcx-0abc123def456

After creating the connection, add routes in both VPCs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# In VPC A: send VPC-B traffic to the peering connection
aws ec2 create-route \
  --route-table-id rtb-aaa \
  --destination-cidr-block 10.1.0.0/16 \
  --vpc-peering-connection-id pcx-0abc123def456

# In VPC B: return path
aws ec2 create-route \
  --route-table-id rtb-bbb \
  --destination-cidr-block 10.0.0.0/16 \
  --vpc-peering-connection-id pcx-0abc123def456

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
resource "aws_ec2_transit_gateway" "main" {
  description                     = "Central network hub"
  auto_accept_shared_attachments  = "disable"  # require explicit acceptance
  default_route_table_association = "disable"  # manage route tables explicitly
  default_route_table_propagation = "disable"

  tags = { Name = "main-tgw" }
}

resource "aws_ec2_transit_gateway_vpc_attachment" "app" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.app.id
  subnet_ids         = aws_subnet.tgw_attachment[*].id

  transit_gateway_default_route_table_association = false
  transit_gateway_default_route_table_propagation = false

  tags = { Name = "tgw-attach-app" }
}

resource "aws_ec2_transit_gateway_route_table" "production" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  tags               = { Name = "production" }
}

resource "aws_ec2_transit_gateway_route_table_association" "app" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.app.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.production.id
}

resource "aws_ec2_transit_gateway_route_table_propagation" "app" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.app.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.production.id
}

In each VPC, add a route to TGW for any CIDR that TGW should route:

1
2
3
4
5
resource "aws_route" "to_tgw" {
  route_table_id         = aws_route_table.private.id
  destination_cidr_block = "10.0.0.0/8"   # all RFC 1918 via TGW
  transit_gateway_id     = aws_ec2_transit_gateway.main.id
}

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 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
resource "aws_vpc_endpoint" "secretsmanager" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.us-east-1.secretsmanager"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.vpc_endpoints.id]
  private_dns_enabled = true  # overrides public DNS to resolve to private IPs

  tags = { Name = "secretsmanager-endpoint" }
}

# S3 uses a Gateway endpoint (different type, different pricing — free)
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.us-east-1.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.private.id]
}

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.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
resource "aws_vpc_endpoint_service" "payments_api" {
  acceptance_required        = true  # manually approve each consumer connection
  network_load_balancer_arns = [aws_lb.payments.arn]

  tags = { Name = "payments-api-endpoint-service" }
}

# Allow a specific AWS account to discover and connect
resource "aws_vpc_endpoint_service_allowed_principal" "consumer" {
  vpc_endpoint_service_id = aws_vpc_endpoint_service.payments_api.id
  principal_arn           = "arn:aws:iam::123456789012:root"
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
resource "aws_eip" "nat" {
  for_each = toset(var.availability_zones)
  domain   = "vpc"
}

resource "aws_nat_gateway" "main" {
  for_each      = toset(var.availability_zones)
  allocation_id = aws_eip.nat[each.key].id
  subnet_id     = aws_subnet.public[each.key].id
  depends_on    = [aws_internet_gateway.main]
}

resource "aws_route" "private_nat" {
  for_each               = toset(var.availability_zones)
  route_table_id         = aws_route_table.private[each.key].id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.main[each.key].id
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
resource "aws_route53_zone" "internal" {
  name = "internal.acme.com"

  vpc {
    vpc_id = aws_vpc.main.id
  }
}

resource "aws_route53_record" "payments_api" {
  zone_id = aws_route53_zone.internal.zone_id
  name    = "payments.internal.acme.com"
  type    = "A"

  alias {
    name                   = aws_lb.payments.dns_name
    zone_id                = aws_lb.payments.zone_id
    evaluate_target_health = true
  }
}

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).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
resource "aws_route53_resolver_endpoint" "inbound" {
  name      = "inbound-resolver"
  direction = "INBOUND"
  security_group_ids = [aws_security_group.resolver.id]

  ip_address {
    subnet_id = aws_subnet.private["us-east-1a"].id
  }
  ip_address {
    subnet_id = aws_subnet.private["us-east-1b"].id
  }
}

resource "aws_route53_resolver_endpoint" "outbound" {
  name      = "outbound-resolver"
  direction = "OUTBOUND"
  security_group_ids = [aws_security_group.resolver.id]

  ip_address {
    subnet_id = aws_subnet.private["us-east-1a"].id
  }
  ip_address {
    subnet_id = aws_subnet.private["us-east-1b"].id
  }
}

resource "aws_route53_resolver_rule" "corp_domain" {
  domain_name          = "corp.example.com"
  name                 = "forward-to-onprem"
  rule_type            = "FORWARD"
  resolver_endpoint_id = aws_route53_resolver_endpoint.outbound.id

  target_ip {
    ip   = "10.32.1.10"  # on-premises DNS server
    port = 53
  }
}

resource "aws_route53_resolver_rule_association" "main" {
  resolver_rule_id = aws_route53_resolver_rule.corp_domain.id
  vpc_id           = aws_vpc.main.id
}

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