LAB37_AWS_Transit Gateway: Hub-and-Spoke Multi-VPC Connectivity

Transit Gateway: Hub-and-Spoke Multi-VPC Connectivity

☁ AWS 🔥 Advanced Transit Gateway Hub-and-Spoke Route Tables TGW Attachments Transitive Routing

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.

Your Objectives
  • 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"
✓ Expected Output After Apply
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..."