Back to Blog Hub

Transit Gateway vs VPC Peering — When Your VPCs Need to Talk

May 1, 2026 18 min read Scaling Enterprise Architecture

AWS Networking Series | Part 4 — Scaling enterprise connectivity and high-performance network orchestration

TGW vs VPC Peering Architecture

TL;DR Comparison

Feature VPC Peering Transit Gateway
TopologyPoint-to-point (1:1)Hub-and-spoke (1:many)
Max connections50 per VPC (soft limit)5,000 attachments per TGW
Transitive routing❌ Not supported✅ Supported
Cross-region✅ Supported ($0.01/GB)✅ Supported (TGW peering)
Cross-account✅ Supported✅ Via AWS RAM
Route managementManual, both sidesCentralised route tables
Traffic isolationSecurity Groups onlyDedicated route tables per segment
BandwidthNo limit50 Gbps burst per AZ
CostFree (in-region) + data transfer$0.05/GB + $0.07/attachment/hour
Best for2-3 high-traffic VPCs10+ VPCs, complex routing

Introduction

In Part 2 of this series we introduced VPC Peering and Transit Gateway as concepts. In this post we go significantly deeper — because choosing the wrong one at scale is one of the most expensive, hardest-to-undo architectural decisions you can make in AWS.

The surface-level answer is simple: small number of VPCs → Peering, large number → TGW. But the reality is far more nuanced. There are workloads where Peering is the right answer at 50 VPCs, and workloads where TGW is the right answer at 3. Understanding why requires understanding transitive routing, route table isolation, centralized egress, and inspection architectures — all of which we cover in detail here.

1. VPC Peering — Direct, Non-Transitive, Fast

How It Works

VPC Peering creates a direct network link between exactly two VPCs. Traffic flows privately over the AWS backbone — no internet, no gateway, no bandwidth bottleneck. The connection is symmetric: both VPCs can initiate traffic to each other.

VPC A (10.0.0.0/16) ←——————→ VPC B (10.1.0.0/16)
         VPC Peering Connection (pcx-xxxxxxxx)

The key operational requirement: routing is not automatic. After creating the peering connection, you must add routes on both sides:

# VPC Peering Connection
resource "aws_vpc_peering_connection" "app_to_data" {
  vpc_id        = aws_vpc.app.id
  peer_vpc_id   = aws_vpc.data.id
  peer_region   = "eu-west-1"   # Same region — omit for cross-region
  auto_accept   = true

  tags = { Name = "app-to-data-peering" }
}

# Route on VPC A side — traffic to VPC B goes via peering
resource "aws_route" "app_to_data" {
  route_table_id            = aws_route_table.app_private.id
  destination_cidr_block    = "10.1.0.0/16"   # VPC B CIDR
  vpc_peering_connection_id = aws_vpc_peering_connection.app_to_data.id
}

# Route on VPC B side — traffic to VPC A goes via peering
resource "aws_route" "data_to_app" {
  route_table_id            = aws_route_table.data_private.id
  destination_cidr_block    = "10.0.0.0/16"   # VPC A CIDR
  vpc_peering_connection_id = aws_vpc_peering_connection.app_to_data.id
}

The Transitivity Problem — The #1 Peering Mistake

This is the most critical concept to understand about VPC Peering. Peering is non-transitive. If VPC A peers with VPC B, and VPC B peers with VPC C, VPC A cannot reach VPC C through VPC B.

VPC A ←——→ VPC B ←——→ VPC C
     ✅ A↔B works    ✅ B↔C works
     ❌ A↔C does NOT work — traffic cannot transit through VPC B

This is not a bug — it is a deliberate design decision by AWS. If you need A to reach C, you must create a direct peering between A and C. This is why full-mesh peering falls apart at scale:

VPCsPeering connections needed for full mesh
33
510
1045
20190
501,225

At 20 VPCs you need 190 peering connections, 380 route table entries, and a spreadsheet to track it all. This is the point where TGW becomes not just preferable but necessary.

Cross-Account Peering

Peering works across AWS accounts. The accepter account must explicitly accept the connection:

# In the requester account
resource "aws_vpc_peering_connection" "cross_account" {
  vpc_id      = aws_vpc.requester.id
  peer_vpc_id = var.accepter_vpc_id
  peer_owner_id = var.accepter_account_id
  peer_region = var.accepter_region
  auto_accept = false   # Must be accepted by the other account

  tags = { Name = "cross-account-peering" }
}

# In the accepter account (separate Terraform workspace/provider)
resource "aws_vpc_peering_connection_accepter" "cross_account" {
  vpc_peering_connection_id = var.peering_connection_id
  auto_accept               = true

  tags = { Name = "cross-account-peering-accepter" }
}

Security Group Referencing Across Peers

One powerful but underused feature: within the same region, you can reference a Security Group from a peered VPC directly in your Security Group rules — no need to use CIDR blocks:

resource "aws_security_group_rule" "allow_peered_app" {
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  source_security_group_id = "sg-xxxxxxxxx"   # SG from peered VPC
  security_group_id        = aws_security_group.rds.id
}

This is far more precise than allowing an entire CIDR block — if the peered VPC's CIDR is 10.1.0.0/16, referencing the SG means only resources in that specific Security Group can connect, not any resource in the entire VPC.

When Peering IS the Right Answer

  • You have 2-5 VPCs that need direct, high-throughput connectivity
  • The VPCs are in the same region (in-region peering is free)
  • You don't need transitive routing — point-to-point is sufficient
  • You want zero data processing cost — unlike TGW, in-region peering has no per-GB charge
  • You need maximum throughput — peering has no bandwidth cap, TGW bursts to 50 Gbps per AZ

2. Transit Gateway — The Enterprise Hub

Architecture Overview

Transit Gateway is a regional network hub that your VPCs and on-premises networks attach to. Instead of point-to-point connections, every VPC connects once to the TGW — the TGW handles all routing decisions from there.

Full TGW Infrastructure — Terraform

# The Transit Gateway itself
resource "aws_ec2_transit_gateway" "main" {
  description                     = "Organization Central Hub"
  amazon_side_asn                 = 64512
  default_route_table_association = "disable"   # We manage route tables manually
  default_route_table_propagation = "disable"   # We manage propagation manually
  auto_accept_shared_attachments  = "enable"    # Auto-accept RAM-shared attachments

  tags = { Name = "org-tgw-eu-west-1" }
}

# Attach Prod VPC
resource "aws_ec2_transit_gateway_vpc_attachment" "prod" {
  subnet_ids         = aws_subnet.prod_private[*].id
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.prod.id
  appliance_mode_support = "enable"   # Required for stateful inspection via GWLB

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

# Attach Non-Prod VPC
resource "aws_ec2_transit_gateway_vpc_attachment" "non_prod" {
  subnet_ids         = aws_subnet.non_prod_private[*].id
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.non_prod.id

  tags = { Name = "tgw-attach-non-prod" }
}

# Attach Shared Services VPC
resource "aws_ec2_transit_gateway_vpc_attachment" "shared" {
  subnet_ids         = aws_subnet.shared_private[*].id
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.shared.id

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

Route Table Isolation — The Core TGW Power

This is where TGW fundamentally outperforms Peering. You create separate route tables per environment and control exactly what each attachment can reach:

# Prod Route Table — Prod VPCs only see each other + Shared Services
resource "aws_ec2_transit_gateway_route_table" "prod" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  tags               = { Name = "tgw-rt-prod" }
}

# Non-Prod Route Table — Non-Prod VPCs only see each other + Shared Services
resource "aws_ec2_transit_gateway_route_table" "non_prod" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  tags               = { Name = "tgw-rt-non-prod" }
}

# ASSOCIATION — Each attachment reads from one route table (its GPS)
resource "aws_ec2_transit_gateway_route_table_association" "prod" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.prod.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.prod.id
}

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

# PROPAGATION — Each attachment writes its CIDR into specific route tables
# Prod VPC announces itself to the Prod RT only
resource "aws_ec2_transit_gateway_route_table_propagation" "prod_to_prod_rt" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.prod.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.prod.id
}

# Shared Services announces itself to BOTH Prod and Non-Prod RTs
# This allows both environments to reach AD, logging, monitoring — but not each other
resource "aws_ec2_transit_gateway_route_table_propagation" "shared_to_prod_rt" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.shared.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.prod.id
}

resource "aws_ec2_transit_gateway_route_table_propagation" "shared_to_non_prod_rt" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.shared.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.non_prod.id
}

The Mental Model:

Association = "I read from this table." | Propagation = "I write into this table."

Sharing TGW Across Accounts — AWS RAM

In a multi-account AWS Organisation, you create one TGW in a central Networking account and share it with all other accounts via Resource Access Manager:

# In the Networking account
resource "aws_ram_resource_share" "tgw" {
  name                      = "org-tgw-share"
  allow_external_principals = false   # Restrict to AWS Organisation only

  tags = { Name = "tgw-ram-share" }
}

resource "aws_ram_resource_association" "tgw" {
  resource_arn       = aws_ec2_transit_gateway.main.arn
  resource_share_arn = aws_ram_resource_share.tgw.arn
}

# Share with the entire AWS Organisation
resource "aws_ram_principal_association" "org" {
  principal          = data.aws_organizations_organization.main.arn
  resource_share_arn = aws_ram_resource_share.tgw.arn
}

# In any spoke account — attaches to the shared TGW
resource "aws_ec2_transit_gateway_vpc_attachment" "spoke" {
  subnet_ids         = aws_subnet.private[*].id
  transit_gateway_id = var.shared_tgw_id   # ID from Networking account
  vpc_id             = aws_vpc.main.id

  tags = { Name = "spoke-attachment-${var.account_name}" }
}

3. Advanced Pattern: Centralised Egress via TGW

This is the enterprise-standard pattern for controlling and auditing all outbound internet traffic from multiple VPCs through a single NAT Gateway in a dedicated Egress VPC.

Full Egress VPC Terraform

# Egress VPC — the only VPC with an IGW
resource "aws_vpc" "egress" {
  cidr_block = "10.255.0.0/24"
  tags       = { Name = "egress-vpc" }
}

resource "aws_internet_gateway" "egress" {
  vpc_id = aws_vpc.egress.id
}

resource "aws_nat_gateway" "egress" {
  for_each      = toset(["eu-west-1a", "eu-west-1b", "eu-west-1c"])
  allocation_id = aws_eip.egress[each.key].id
  subnet_id     = aws_subnet.egress_public[each.key].id
  tags          = { Name = "nat-gw-egress-${each.key}" }
}

# Attach Egress VPC to TGW
resource "aws_ec2_transit_gateway_vpc_attachment" "egress" {
  subnet_ids         = aws_subnet.egress_private[*].id
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.egress.id
  appliance_mode_support = "enable"   # Critical for asymmetric flows
  tags               = { Name = "tgw-attach-egress" }
}

# In each spoke VPC's route table — default route points to TGW
resource "aws_route" "spoke_default_via_tgw" {
  route_table_id         = aws_route_table.spoke_private.id
  destination_cidr_block = "0.0.0.0/0"
  transit_gateway_id     = aws_ec2_transit_gateway.main.id
}

# In the TGW route table — default route points to Egress VPC attachment
resource "aws_ec2_transit_gateway_route" "default_to_egress" {
  destination_cidr_block         = "0.0.0.0/0"
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.egress.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.prod.id
}

# In Egress VPC — route internet-bound traffic via NAT GW, return traffic via TGW
resource "aws_route" "egress_return_to_spokes" {
  route_table_id         = aws_route_table.egress_private.id
  destination_cidr_block = "10.0.0.0/8"   # All spoke CIDRs
  transit_gateway_id     = aws_ec2_transit_gateway.main.id
}

4. Advanced Pattern: Centralised Inspection via GWLB

For high-security workloads, all traffic may need to be inspected by a third-party firewall fleet. Gateway Load Balancer (GWLB) enables this transparently.

Full GWLB Infrastructure

# Inspection VPC with GWLB
resource "aws_lb" "inspection" {
  name               = "inspection-gwlb"
  load_balancer_type = "gateway"
  subnets            = aws_subnet.inspection[*].id

  tags = { Name = "gwlb-inspection" }
}

resource "aws_lb_target_group" "firewalls" {
  name        = "firewall-targets"
  port        = 6081    # GENEVE protocol — GWLB uses this for encapsulation
  protocol    = "GENEVE"
  vpc_id      = aws_vpc.inspection.id
  target_type = "instance"

  health_check {
    port     = 80
    protocol = "HTTP"
  }
}

# VPC Endpoint Service — exposes GWLB to other VPCs via PrivateLink
resource "aws_vpc_endpoint_service" "inspection" {
  acceptance_required        = false
  gateway_load_balancer_arns = [aws_lb.inspection.arn]

  tags = { Name = "gwlb-endpoint-service" }
}

# In each spoke VPC — a GWLB endpoint that redirects traffic to inspection
resource "aws_vpc_endpoint" "gwlb_spoke" {
  vpc_id            = aws_vpc.spoke.id
  service_name      = aws_vpc_endpoint_service.inspection.service_name
  vpc_endpoint_type = "GatewayLoadBalancer"
  subnet_ids        = aws_subnet.spoke_private[*].id

  tags = { Name = "gwlb-endpoint-spoke" }
}

5. Inter-Region TGW Peering — Global Hub-and-Spoke

To connect VPCs across regions, you peer Transit Gateways together. Traffic flows entirely over the AWS private backbone.

# In eu-west-1
resource "aws_ec2_transit_gateway_peering_attachment" "eu_to_us" {
  transit_gateway_id      = aws_ec2_transit_gateway.eu.id
  peer_transit_gateway_id = var.us_east_1_tgw_id
  peer_region             = "us-east-1"

  tags = { Name = "tgw-peer-eu-to-us" }
}

# In us-east-1 — must be accepted
resource "aws_ec2_transit_gateway_peering_attachment_accepter" "us" {
  transit_gateway_attachment_id = var.peering_attachment_id

  tags = { Name = "tgw-peer-us-accepter" }
}

# Static routes required — no propagation across TGW peering
resource "aws_ec2_transit_gateway_route" "eu_to_us_cidrs" {
  destination_cidr_block         = "10.100.0.0/16"   # US VPC CIDRs
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_peering_attachment.eu_to_us.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.prod.id
}

6. Cost Deep-Dive — When Does TGW Actually Save Money?

The most honest part of this post. TGW has two cost components that stack up fast:

ComponentCost (eu-west-1)
TGW hourly$0.073/attachment/hour
Data processed$0.02/GB
Inter-region peering$0.02/GB (each side)

Scenario: 10 VPCs, All Needing Full-Mesh Connectivity

Option A — VPC Peering (full mesh, same region):

  • Peering connections needed: 45
  • Route table entries needed: ~900
  • Hourly cost: $0
  • Data transfer (in-region): $0
  • Operational overhead: Very High

Option B — Transit Gateway:

  • TGW attachments: 10 VPCs
  • Hourly cost: 10 × $0.073 × 730h = $532.90/month
  • Data processed (est 1TB/mo): 1,000 GB × $0.02 = $20.00/month
  • Total: ~$552.90/month
  • Operational overhead: Low (centralised)

7. The Decision Framework

How many VPCs need to communicate?
├── 2-5 VPCs
│ ├── Same region + high throughput (>1TB/month)?
│ │ └── VPC Peering (free data transfer, no bottleneck)
│ └── Need transitive routing or centralized control?
│ └── Transit Gateway
└── 6+ VPCs
└── Transit Gateway (always — operational complexity of peering is too high)

Do you need traffic inspection / centralized security?
└── YES → TGW + Inspection VPC (GWLB + firewall fleet)

Do you need centralized egress?
└── YES → TGW + Egress VPC (single NAT GW per AZ for all spokes)

Are CIDRs overlapping?
└── YES → TGW + Private NAT Gateway

8. Common Mistakes & Anti-Patterns

  • Full-Mesh Peering Beyond 5 VPCs: Managing 45+ peering connections manually is an operational nightmare.
  • Forgetting appliance_mode_support: Without this, stateful NAT will drop return traffic when packets enter and exit through different AZs.
  • Expecting Propagation to Work Across TGW Peering: Route propagation only works within a single TGW. Across a peering connection, all routes must be static.
  • Not Disabling Defaults: Always disable default_route_table_association to prevent all new attachments from leaking routes across environments.
  • Using TGW for Latency-Sensitive HPC: TGW adds a small hop. For ultra-low-latency financial trading or HPC, direct Peering is faster.
RequirementVPC PeeringTransit Gateway
2-3 VPCs, same region✅ Best choice❌ Overkill
10+ VPCs❌ Unmanageable✅ Best choice
Zero data transfer cost✅ In-region free❌ $0.02/GB
Transitive routing❌ Not supported✅ Native
Centralized egress❌ Not possible✅ Egress VPC pattern
Multi-account (AWS Org)✅ Manual per-pair✅ Single TGW via RAM

The Golden Rule

"Use VPC Peering for direct, high-throughput connectivity between 2-5 VPCs. Use Transit Gateway for centralized control, multi-account scale, and advanced inspection. When in doubt — if you're asking if you need TGW, you probably need TGW."

Tags: #AWS #TransitGateway #VPCPeering #Networking #FinOps #IaC #Terraform

Ankush Panday

Specializing in highly scalable AWS infrastructure and automated quality engineering.

Connect on LinkedIn