Guide · AWS Transit Gateway · Multi-VPC Networking
AWS Transit Gateway for MCP Server Infrastructure — Multi-VPC Hub, Custom Route Tables, and Cross-Account Sharing
When an MCP server deployment spans more than two or three VPCs — production, staging, shared-services, data — VPC peering quickly becomes unmanageable: with 5 VPCs you need 10 peering connections, and peering is non-transitive (A→B and B→C does not allow A→C). AWS Transit Gateway (TGW) acts as a regional hub: every VPC attaches once, and the TGW routes traffic between all attached VPCs, VPNs, and Direct Connect gateways through a central set of route tables. The canonical MCP infrastructure topology is three VPCs: shared-services (centralized VPC endpoints for Secrets Manager, ECR, Bedrock), production (MCP server ECS cluster), and data (RDS, DynamoDB, S3 with strict access policies). TGW connects all three with a single attachment per VPC. Custom route tables in TGW enforce isolation: dev VPCs can reach shared-services but not production; production can reach data but dev cannot. Cross-account sharing via AWS Resource Access Manager (RAM) lets each team account attach to a single TGW in the networking account rather than managing per-account TGW infrastructure.
TL;DR
Create a TGW, attach each VPC with one TGW attachment per AZ subnet, configure route tables to control which VPCs can reach which, and use AWS RAM to share the TGW with other AWS accounts. Add a default route (0.0.0.0/0) pointing to a centralized egress VPC attachment if all outbound internet traffic should flow through a shared NAT or Network Firewall. Custom route tables enforce spoke isolation — dev and prod each have their own route table association that only sees routes to shared-services, not to each other.
TGW creation and VPC attachment
Creating a TGW takes about 5 minutes for propagation. The TGW belongs to one AWS account but can be shared to others via RAM. Specify --auto-accept-shared-attachments enable only if you trust all accounts that can share the TGW — otherwise approve each attachment manually for security.
# Create a Transit Gateway in the networking/infrastructure account
TGW_ID=$(aws ec2 create-transit-gateway \
--description "MCP Infrastructure Hub" \
--options '{
"AmazonSideAsn": 64512,
"AutoAcceptSharedAttachments": "disable",
"DefaultRouteTableAssociation": "disable",
"DefaultRouteTablePropagation": "disable",
"DnsSupport": "enable",
"VpnEcmpSupport": "enable",
"MulticastSupport": "disable"
}' \
--query 'TransitGateway.TransitGatewayId' --output text)
# Wait for TGW to become available
aws ec2 wait transit-gateway-available \
--filters "Name=transit-gateway-id,Values=$TGW_ID"
# Attach the production VPC (one subnet per AZ for high availability)
PROD_ATTACH=$(aws ec2 create-transit-gateway-vpc-attachment \
--transit-gateway-id $TGW_ID \
--vpc-id vpc-0production \
--subnet-ids subnet-0prod-a subnet-0prod-b subnet-0prod-c \
--query 'TransitGatewayVpcAttachment.TransitGatewayAttachmentId' --output text)
# Attach the shared-services VPC (holds centralized VPC endpoints)
SHARED_ATTACH=$(aws ec2 create-transit-gateway-vpc-attachment \
--transit-gateway-id $TGW_ID \
--vpc-id vpc-0shared-services \
--subnet-ids subnet-0shared-a subnet-0shared-b \
--query 'TransitGatewayVpcAttachment.TransitGatewayAttachmentId' --output text)
# Attach the data VPC (RDS, DynamoDB-managed tables, S3 bucket)
DATA_ATTACH=$(aws ec2 create-transit-gateway-vpc-attachment \
--transit-gateway-id $TGW_ID \
--vpc-id vpc-0data \
--subnet-ids subnet-0data-a subnet-0data-b \
--query 'TransitGatewayVpcAttachment.TransitGatewayAttachmentId' --output text)
# In each VPC's private subnet route table, add a route to TGW for other VPC CIDRs
# Production VPC: route to shared-services and data VPCs via TGW
aws ec2 create-route \
--route-table-id rtb-0prod-private \
--destination-cidr-block 10.1.0.0/16 \ # shared-services VPC CIDR
--transit-gateway-id $TGW_ID
aws ec2 create-route \
--route-table-id rtb-0prod-private \
--destination-cidr-block 10.2.0.0/16 \ # data VPC CIDR
--transit-gateway-id $TGW_ID
Disable DefaultRouteTableAssociation and DefaultRouteTablePropagation — with defaults enabled, every new attachment automatically joins the default route table and advertises its routes to all other attachments, which defeats the purpose of isolation. With both disabled you have full control over which attachments can see which routes. This is a one-time TGW creation option — it cannot be changed after creation.
Custom route tables for traffic isolation
TGW route tables are the core isolation mechanism. Each attachment is associated with exactly one route table (which routes the attachment looks up when forwarding traffic) and can propagate its CIDR into any number of route tables. The canonical pattern: shared-services gets a "hub" route table that sees all spoke CIDRs; production and dev each get their own isolated route tables that only see shared-services routes.
# Create route tables for each isolation domain
HUB_RT=$(aws ec2 create-transit-gateway-route-table \
--transit-gateway-id $TGW_ID \
--tag-specifications "ResourceType=transit-gateway-route-table,Tags=[{Key=Name,Value=hub}]" \
--query 'TransitGatewayRouteTable.TransitGatewayRouteTableId' --output text)
PROD_RT=$(aws ec2 create-transit-gateway-route-table \
--transit-gateway-id $TGW_ID \
--tag-specifications "ResourceType=transit-gateway-route-table,Tags=[{Key=Name,Value=production}]" \
--query 'TransitGatewayRouteTable.TransitGatewayRouteTableId' --output text)
DEV_RT=$(aws ec2 create-transit-gateway-route-table \
--transit-gateway-id $TGW_ID \
--tag-specifications "ResourceType=transit-gateway-route-table,Tags=[{Key=Name,Value=development}]" \
--query 'TransitGatewayRouteTable.TransitGatewayRouteTableId' --output text)
# Associate attachments to route tables (which table the attachment LOOKS UP)
aws ec2 associate-transit-gateway-route-table \
--transit-gateway-route-table-id $PROD_RT \
--transit-gateway-attachment-id $PROD_ATTACH
aws ec2 associate-transit-gateway-route-table \
--transit-gateway-route-table-id $DEV_RT \
--transit-gateway-attachment-id $DEV_ATTACH
aws ec2 associate-transit-gateway-route-table \
--transit-gateway-route-table-id $HUB_RT \
--transit-gateway-attachment-id $SHARED_ATTACH
# Enable route propagation — which attachments ADVERTISE their routes into which tables
# Shared-services advertises into BOTH prod and dev route tables
aws ec2 enable-transit-gateway-route-table-propagation \
--transit-gateway-route-table-id $PROD_RT \
--transit-gateway-attachment-id $SHARED_ATTACH
aws ec2 enable-transit-gateway-route-table-propagation \
--transit-gateway-route-table-id $DEV_RT \
--transit-gateway-attachment-id $SHARED_ATTACH
# Production and dev advertise into HUB table (so shared-services can route back)
aws ec2 enable-transit-gateway-route-table-propagation \
--transit-gateway-route-table-id $HUB_RT \
--transit-gateway-attachment-id $PROD_ATTACH
aws ec2 enable-transit-gateway-route-table-propagation \
--transit-gateway-route-table-id $HUB_RT \
--transit-gateway-attachment-id $DEV_ATTACH
# Result: prod → shared OK; dev → shared OK; prod ↔ dev NOT routable
Route propagation is directional — enabling propagation of the prod attachment into the hub route table means the hub can reach prod, not that prod can reach hub. The prod-rt association controls what prod looks up; prod sees shared-services CIDR because shared propagates into prod-rt, but prod cannot see dev's CIDR because dev does not propagate into prod-rt. This asymmetry is the key to spoke isolation without firewall rules.
Blackhole routes and centralized egress
Blackhole routes in TGW route tables silently drop packets matching the route. Use them to prevent accidental connectivity across isolation boundaries — even if a misconfigured VPC route table sends traffic to TGW for a CIDR that should be unreachable, TGW drops it before it reaches the destination.
# Add a blackhole for RFC 1918 space not explicitly allowed in prod-rt
# Prevents accidental routing to new VPCs that get attached but not approved
aws ec2 create-transit-gateway-route \
--transit-gateway-route-table-id $PROD_RT \
--destination-cidr-block 10.0.0.0/8 \
--blackhole
aws ec2 create-transit-gateway-route \
--transit-gateway-route-table-id $PROD_RT \
--destination-cidr-block 172.16.0.0/12 \
--blackhole
# Then add specific allowed routes with lower prefix length (more specific = preferred)
# TGW uses longest-prefix match just like VPC routing
aws ec2 create-transit-gateway-route \
--transit-gateway-route-table-id $PROD_RT \
--destination-cidr-block 10.1.0.0/16 \ # shared-services — allowed
--transit-gateway-attachment-id $SHARED_ATTACH
# Centralized egress: all spoke VPCs route 0.0.0.0/0 to TGW
# TGW routes 0.0.0.0/0 in spoke route tables to egress VPC attachment
EGRESS_ATTACH="tgw-attach-0egress123" # egress VPC with NAT or Network Firewall
aws ec2 create-transit-gateway-route \
--transit-gateway-route-table-id $PROD_RT \
--destination-cidr-block 0.0.0.0/0 \
--transit-gateway-attachment-id $EGRESS_ATTACH
# In the egress VPC's route table: internet-bound traffic → IGW after inspection
# Returning traffic: IGW edge RT maps spoke VPC CIDRs back through TGW
aws ec2 create-route \
--route-table-id rtb-0egress-private \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id igw-0egress
# Route return traffic to TGW for spoke VPC CIDRs
aws ec2 create-route \
--route-table-id rtb-0egress-public \ # IGW edge RT
--destination-cidr-block 10.0.0.0/8 \
--transit-gateway-id $TGW_ID
The centralized egress pattern eliminates per-VPC NAT gateways — all internet traffic flows through a single egress VPC where you can inspect it with a Network Firewall, log it, or apply WAF rules. The cost tradeoff: TGW attachment ($0.05/hr) + TGW data processing ($0.02/GB) vs per-VPC NAT gateway ($0.045/hr + $0.045/GB). With 5+ VPCs, TGW + single NAT is cheaper than 5 NAT gateways even before adding the operational benefit.
Cross-account sharing with AWS RAM
Share the TGW with other AWS accounts or with the entire AWS Organization using Resource Access Manager. Each account then creates its own VPC attachment to the shared TGW — no separate TGW per account is needed. The networking account owns and pays for the TGW; each spoke account pays only for their attachment and data processing.
# Share the TGW with the entire AWS Organization
SHARE_ARN=$(aws ram create-resource-share \
--name MCPInfrastructureTGW \
--resource-arns arn:aws:ec2:us-east-1:NETWORK_ACCOUNT:transit-gateway/$TGW_ID \
--principals arn:aws:organizations::MASTER_ACCOUNT:organization/o-abc123 \
--query 'resourceShare.resourceShareArn' --output text)
# Or share with specific accounts only
aws ram create-resource-share \
--name MCPInfrastructureTGW \
--resource-arns arn:aws:ec2:us-east-1:NETWORK_ACCOUNT:transit-gateway/$TGW_ID \
--principals \
"123456789012" \ # prod account
"333344445555" # dev account
# In the spoke account: accept the RAM invitation (if not in same Org)
aws ram accept-resource-share-invitation \
--resource-share-invitation-arn arn:aws:ram:us-east-1:NETWORK_ACCOUNT:resource-share-invitation/xxx
# In the spoke account: create VPC attachment to the shared TGW
aws ec2 create-transit-gateway-vpc-attachment \
--transit-gateway-id $TGW_ID \ # same TGW ID as networking account
--vpc-id vpc-0spoke-account \
--subnet-ids subnet-0spoke-a subnet-0spoke-b
# In the networking account: accept the cross-account attachment
aws ec2 accept-transit-gateway-vpc-attachment \
--transit-gateway-attachment-id tgw-attach-0new-spoke
Cross-account attachments appear in the networking account as pending until accepted. After acceptance, the networking account administrator assigns the attachment to a route table (association) and optionally enables propagation. The spoke account does not need to know about TGW route tables — they only see the attachment endpoint in their account. This gives the networking account full control over which routes the new spoke can reach, without the spoke account being able to modify TGW routing.
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Traffic not routing between attached VPCs | Ping and TCP connections between VPCs fail despite TGW attachment | Check four things: (1) VPC subnet route table has a route pointing to TGW, (2) TGW attachment is in "available" state, (3) TGW route table has a route for the destination CIDR, (4) security groups allow the traffic — TGW is transparent, security groups still apply |
| Cross-account attachment stuck in pendingAcceptance | Spoke account created attachment but it never becomes available | Networking account must run accept-transit-gateway-vpc-attachment; check with describe-transit-gateway-attachments --filters Name=state,Values=pendingAcceptance in networking account |
| Blackhole route blocking intended traffic | Traffic drops silently for a specific CIDR; no error logs | TGW blackhole routes have no logging — use VPC Flow Logs on the source VPC ENI to see the packet leaving the subnet, then check TGW route tables with search-transit-gateway-routes for the destination CIDR to identify which route (blackhole or attachment) matches |
| Centralized egress asymmetric routing | Outbound connections succeed but responses are dropped; TCP sessions fail | Return traffic from IGW must be routed back through TGW to the spoke VPC; the IGW edge route table in the egress VPC must have routes for all spoke VPC CIDRs pointing to TGW; missing this causes asymmetric routing where response packets miss the stateful firewall connection state |
| RAM share not visible in spoke account | Spoke account cannot find shared TGW even after RAM share creation | If the TGW and spoke account are in the same AWS Organization, RAM shares are available immediately without an acceptance step; enable AWS RAM integration with Organizations via aws ram enable-sharing-with-aws-organization in the master account |