Transit Gateway: Hub-and-Spoke Multi-VPC Connectivity
Scenario
Your organisation has three VPCs: App, Shared Services (DNS, Active Directory), and Egress (with a NAT Gateway for shared internet access). VPC Peering does not support transitive routing — App cannot reach Egress through Shared Services. You need AWS Transit Gateway to connect all three VPCs in a hub-and-spoke topology with full transitive routing.
- Create a Transit Gateway with DNS support, VPN ECMP support, and default route table association disabled.
- Create VPC attachments for all three VPCs using for_each over a map of VPC IDs and subnets.
- Create a single TGW route table and associate all three attachments with it. Propagate routes from all attachments into this table.
- Add a default route (0.0.0.0/0) in the App and Shared Services VPC route tables pointing to the TGW attachment (for internet traffic via the Egress VPC).
- Create a static TGW route for 0.0.0.0/0 → the Egress VPC attachment in the TGW route table.
- Output the TGW ID, all attachment IDs, and the TGW route table ID.
Additional Context
Unlike VPC Peering, Transit Gateway supports transitive routing — traffic from App VPC can flow App → TGW → Egress VPC without a direct peering between App and Egress. TGW centralises routing logic: each VPC only needs one attachment to the TGW, and the TGW route tables control which destinations are reachable.
Disabling default_route_table_association and default_route_table_propagation gives you full control. If you leave defaults on, every new attachment automatically joins the default route table — which can cause routing conflicts as your TGW grows.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "environment" { type = string; default = "prod" }
variable "vpcs" {
description = "Map of VPC definitions for TGW attachment."
type = map(object({
vpc_id = string
subnet_ids = list(string)
cidr = string
route_table_id = string
}))
}main.tf
provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }
# Transit Gateway — the central hub for all VPC connectivity.
resource "aws_ec2_transit_gateway" "main" {
description = "Hub TGW — ${var.environment}"
amazon_side_asn = 64512
dns_support = "enable"
vpn_ecmp_support = "enable"
# Disable default route table so we control associations explicitly.
default_route_table_association = "disable"
default_route_table_propagation = "disable"
tags = merge(local.tags, { Name = "hub-tgw-${var.environment}" })
}
# TGW Attachments — one per VPC, driven by for_each.
resource "aws_ec2_transit_gateway_vpc_attachment" "vpcs" {
for_each = var.vpcs
transit_gateway_id = aws_ec2_transit_gateway.main.id
vpc_id = each.value.vpc_id
subnet_ids = each.value.subnet_ids
# Disable default route table on the attachment too.
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
tags = merge(local.tags, { Name = "tgw-attach-${each.key}" })
}
# Single TGW route table — all VPCs share one routing domain.
resource "aws_ec2_transit_gateway_route_table" "main" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
tags = merge(local.tags, { Name = "tgw-rt-${var.environment}" })
}
# Associate all VPC attachments with the TGW route table.
resource "aws_ec2_transit_gateway_route_table_association" "vpcs" {
for_each = var.vpcs
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.vpcs[each.key].id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.main.id
}
# Propagate each VPC's CIDR into the TGW route table automatically.
resource "aws_ec2_transit_gateway_route_table_propagation" "vpcs" {
for_each = var.vpcs
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.vpcs[each.key].id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.main.id
}
# Static default route in TGW route table — all internet traffic goes to the Egress VPC.
resource "aws_ec2_transit_gateway_route" "default" {
destination_cidr_block = "0.0.0.0/0"
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.vpcs["egress"].id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.main.id
depends_on = [aws_ec2_transit_gateway_route_table_propagation.vpcs]
}
# Add default route in App and Shared Services VPCs pointing to TGW.
resource "aws_route" "to_tgw" {
for_each = {
for k, v in var.vpcs : k => v if k != "egress"
}
route_table_id = each.value.route_table_id
destination_cidr_block = "0.0.0.0/0"
transit_gateway_id = aws_ec2_transit_gateway.main.id
depends_on = [aws_ec2_transit_gateway_vpc_attachment.vpcs]
}outputs.tf
output "tgw_id" { value = aws_ec2_transit_gateway.main.id }
output "tgw_route_table_id" { value = aws_ec2_transit_gateway_route_table.main.id }
output "attachment_ids" {
value = { for k, v in aws_ec2_transit_gateway_vpc_attachment.vpcs : k => v.id }
}Example tfvars
# terraform.tfvars
environment = "prod"
vpcs = {
app = {
vpc_id = "vpc-0app..."
subnet_ids = ["subnet-0app-a", "subnet-0app-b"]
cidr = "10.0.0.0/16"
route_table_id = "rtb-0app..."
}
shared = {
vpc_id = "vpc-0shared..."
subnet_ids = ["subnet-0shared-a", "subnet-0shared-b"]
cidr = "10.1.0.0/16"
route_table_id = "rtb-0shared..."
}
egress = {
vpc_id = "vpc-0egress..."
subnet_ids = ["subnet-0egress-a", "subnet-0egress-b"]
cidr = "10.2.0.0/16"
route_table_id = "rtb-0egress..."
}
}Workflow Commands
terraform init
terraform apply -var-file="terraform.tfvars"
# Inspect TGW route table — should have propagated routes from all VPCs + static default
aws ec2 search-transit-gateway-routes \
--transit-gateway-route-table-id $(terraform output -raw tgw_route_table_id) \
--filters "Name=state,Values=active"
# Test transitive routing: from EC2 in App VPC, ping a resource in Shared Services VPC
# and verify it reaches the internet via the Egress VPC NAT
curl -s --max-time 5 https://ifconfig.me # from App VPC EC2 (should return Egress EIP)
terraform destroy -var-file="terraform.tfvars"Apply complete! Resources: 11 added, 0 changed, 0 destroyed.
Outputs:
attachment_ids = {
"app" = "tgw-attach-0aaa..."
"egress" = "tgw-attach-0bbb..."
"shared" = "tgw-attach-0ccc..."
}
tgw_id = "tgw-0ddd..."
tgw_route_table_id = "tgw-rtb-0eee..."
