VPC Peering vs Transit Gateway: A Complete Cost Analysis

When to use VPC peering versus Transit Gateway with detailed cost modeling for multi-account AWS architectures.

#aws#vpc#networking#cost-optimization
Cover image for the article: VPC Peering vs Transit Gateway: A Complete Cost Analysis

The VPC peering versus Transit Gateway decision is one that every growing AWS organization faces. At 3-4 VPCs, peering is simple and cheap. At 15+ VPCs, the mesh becomes unmanageable. But the crossover point — where Transit Gateway becomes more cost-effective — depends heavily on your traffic patterns, not just your VPC count. This is closely related to understanding cross-region latency penalties that also vary by architecture choice.

After migrating a 23-VPC architecture from full-mesh peering to Transit Gateway, then partially rolling it back for high-traffic pairs, I can share concrete cost data on where each approach wins.

The Fundamental Trade-off

VPC Peering:

  • No hourly charge
  • $0.01/GB data transfer (same region, cross-AZ)
  • Free for same-AZ traffic
  • Maximum 125 peering connections per VPC
  • No transitive routing

Transit Gateway:

  • $0.05/hour per attachment (~$36/month per VPC)
  • $0.02/GB data processed
  • Supports transitive routing
  • Centralized route management
  • Up to 5,000 attachments

The per-GB cost difference is significant: Transit Gateway costs 2x per GB compared to peering for same-region traffic. But operational complexity costs money too.

Cost Model: The Math

from dataclasses import dataclass


@dataclass
class NetworkTopology:
    num_vpcs: int
    monthly_gb_per_pair: float  # Average GB transferred between any two VPC pairs
    active_pairs_ratio: float   # % of possible pairs that actually communicate
    hours_per_month: int = 730


def peering_cost(topology: NetworkTopology) -> dict:
    """Calculate monthly cost of full-mesh VPC peering."""
    # Number of peering connections needed for full mesh
    num_connections = (topology.num_vpcs * (topology.num_vpcs - 1)) // 2
    active_connections = int(num_connections * topology.active_pairs_ratio)
    
    # Peering has no hourly cost, only data transfer
    total_gb = active_connections * topology.monthly_gb_per_pair
    data_cost = total_gb * 0.01  # $0.01/GB same-region
    
    # Operational cost: each connection needs route table entries in both VPCs
    route_entries = active_connections * 2
    
    return {
        'connections': num_connections,
        'active_connections': active_connections,
        'total_gb': total_gb,
        'monthly_cost': data_cost,
        'route_entries': route_entries,
        'cost_per_gb': 0.01
    }


def transit_gateway_cost(topology: NetworkTopology) -> dict:
    """Calculate monthly cost of Transit Gateway."""
    # Hourly attachment cost
    attachment_cost = topology.num_vpcs * 0.05 * topology.hours_per_month
    
    # Data processing cost
    active_pairs = int(
        ((topology.num_vpcs * (topology.num_vpcs - 1)) // 2) 
        * topology.active_pairs_ratio
    )
    total_gb = active_pairs * topology.monthly_gb_per_pair
    data_cost = total_gb * 0.02  # $0.02/GB processed
    
    return {
        'attachments': topology.num_vpcs,
        'attachment_cost': attachment_cost,
        'total_gb': total_gb,
        'data_cost': data_cost,
        'monthly_cost': attachment_cost + data_cost,
        'route_entries': topology.num_vpcs,  # One route per VPC to TGW
        'cost_per_gb': 0.02 + (attachment_cost / max(total_gb, 1))
    }


def find_crossover(max_vpcs: int = 50, gb_per_pair: float = 100.0) -> None:
    """Find the VPC count where Transit Gateway becomes cheaper."""
    print(f"{'VPCs':<6} {'Peering':<12} {'TGW':<12} {'Winner':<10}")
    print("-" * 40)
    
    for num_vpcs in range(2, max_vpcs + 1, 2):
        topology = NetworkTopology(
            num_vpcs=num_vpcs,
            monthly_gb_per_pair=gb_per_pair,
            active_pairs_ratio=0.4
        )
        
        peer = peering_cost(topology)
        tgw = transit_gateway_cost(topology)
        
        winner = "Peering" if peer['monthly_cost'] < tgw['monthly_cost'] else "TGW"
        print(f"{num_vpcs:<6} ${peer['monthly_cost']:<10,.0f} ${tgw['monthly_cost']:<10,.0f} {winner}")


# Run analysis
find_crossover(max_vpcs=30, gb_per_pair=50.0)

Real-World Results

Running this model against our actual traffic patterns:

VPC CountActive PairsAvg GB/PairPeering CostTGW CostWinner
54200 GB$8$219Peering
1018150 GB$27$613Peering
1542100 GB$42$1,387Peering (by cost)
207680 GB$61$2,581Depends on ops
2512060 GB$72$3,877TGW (by ops)

The pure cost crossover almost never favors Transit Gateway for same-region traffic. Transit Gateway wins on operational simplicity, not raw transfer cost.

When Transit Gateway Wins

Transit Gateway becomes the right choice when:

  1. You need transitive routing. VPC A talks to VPC B, which talks to VPC C. With peering, you need A-B AND A-C connections. With TGW, A reaches C through B's routes automatically.

  2. You have more than 15 VPCs. Managing N*(N-1)/2 peering connections becomes untenable. At 20 VPCs, that's 190 connections.

  3. You need centralized network policies. TGW route tables provide centralized control over what can communicate with what.

  4. You connect to on-premises. TGW integrates with Direct Connect and VPN, providing a single transit point.

The Hybrid Approach

Our final architecture uses both:

# Transit Gateway for general connectivity
resource "aws_ec2_transit_gateway" "main" {
  description                     = "Central transit gateway"
  default_route_table_association = "disable"
  default_route_table_propagation = "disable"
  
  tags = {
    Name = "central-tgw"
  }
}

# TGW attachments for all VPCs
resource "aws_ec2_transit_gateway_vpc_attachment" "vpcs" {
  for_each = var.vpc_configs

  subnet_ids         = each.value.tgw_subnet_ids
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = each.value.vpc_id

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

# Direct peering for high-traffic pairs (>500 GB/month)
resource "aws_vpc_peering_connection" "high_traffic" {
  for_each = var.high_traffic_pairs

  vpc_id      = each.value.requester_vpc_id
  peer_vpc_id = each.value.accepter_vpc_id
  auto_accept = true

  tags = {
    Name   = "peer-${each.value.requester}-${each.value.accepter}"
    Reason = "high-traffic-cost-optimization"
    MonthlyGB = each.value.estimated_monthly_gb
  }
}

Decision Framework

Use this decision tree:

  1. < 5 VPCs, no on-prem: VPC Peering only
  2. 5-15 VPCs, no transitive needs: VPC Peering with automation
  3. 5-15 VPCs, need transitive routing: Transit Gateway
  4. 15+ VPCs: Transit Gateway + direct peering for top 5 traffic pairs
  5. Any count with on-premises connectivity: Transit Gateway required

Monitoring the Hybrid

import boto3
from datetime import datetime, timedelta


def compare_path_costs(tgw_id: str, peering_ids: list[str], region: str) -> dict:
    """Compare costs between TGW and peering paths for optimization decisions."""
    cloudwatch = boto3.client('cloudwatch', region_name=region)
    
    # Get TGW bytes processed
    tgw_metrics = cloudwatch.get_metric_statistics(
        Namespace='AWS/TransitGateway',
        MetricName='BytesIn',
        Dimensions=[{'Name': 'TransitGateway', 'Value': tgw_id}],
        StartTime=datetime.utcnow() - timedelta(days=30),
        EndTime=datetime.utcnow(),
        Period=86400 * 30,
        Statistics=['Sum']
    )
    
    tgw_bytes = sum(dp['Sum'] for dp in tgw_metrics['Datapoints'])
    tgw_gb = tgw_bytes / (1024**3)
    tgw_cost = tgw_gb * 0.02  # Data processing only
    
    # Get peering traffic for comparison
    peering_traffic = {}
    for peer_id in peering_ids:
        metrics = cloudwatch.get_metric_statistics(
            Namespace='AWS/VPC',
            MetricName='BytesTransferred',
            Dimensions=[{'Name': 'VpcPeeringConnectionId', 'Value': peer_id}],
            StartTime=datetime.utcnow() - timedelta(days=30),
            EndTime=datetime.utcnow(),
            Period=86400 * 30,
            Statistics=['Sum']
        )
        peer_gb = sum(dp['Sum'] for dp in metrics['Datapoints']) / (1024**3)
        peering_traffic[peer_id] = {
            'gb': peer_gb,
            'cost': peer_gb * 0.01,
            'tgw_equivalent_cost': peer_gb * 0.02
        }
    
    return {
        'tgw': {'gb': tgw_gb, 'cost': tgw_cost},
        'peering': peering_traffic,
        'savings_from_peering': sum(
            p['tgw_equivalent_cost'] - p['cost'] 
            for p in peering_traffic.values()
        )
    }

Key Takeaways

  1. Cost alone favors peering. Transit Gateway will almost always cost more per GB for same-region traffic.
  2. Operational cost is real. Managing 100+ peering connections requires automation that has its own maintenance burden.
  3. Hybrid is usually optimal. Use TGW for general connectivity, direct peering for your top traffic pairs.
  4. Monitor and adjust quarterly. Traffic patterns change. A pair that justified peering last quarter might not this quarter.
  5. Factor in growth. If you're adding 3-5 VPCs per quarter, start with TGW to avoid rearchitecting later.

The right answer is almost never pure peering or pure Transit Gateway. It's a hybrid that optimizes for both cost and operational sanity, reviewed quarterly as traffic patterns evolve. For the broader picture of managing AWS networking costs, see the data transfer cost optimization guide and NAT Gateway cost reduction patterns.

Comments

    No comments yet. Be the first to share your thoughts.