AWS Transit Gateway: Connecting Multiple VPCs Without the Peering Complexity

VPC peering works fine for two or three VPCs. Each peering connection is a direct link between two specific VPCs, non-transitive, with its own route table entries on both sides. At five VPCs you are managing ten peering connections. At ten VPCs you need forty-five. At twenty you need one hundred and ninety. The mesh becomes unmanageable and the blast radius of a misconfiguration grows with every connection you add.

AWS Transit Gateway replaces that mesh with a central hub. Every VPC connects to the Transit Gateway once. The Transit Gateway routes traffic between them according to route tables you define. Add a new VPC: one attachment, one route table entry, done. In this article I will walk through Transit Gateway architecture, route table segmentation, and connecting on-premises networks through the same hub.

How Transit Gateway Works

A Transit Gateway is a regional resource. VPCs in the same region attach to it directly. VPCs in other regions connect through inter-region peering between Transit Gateways. On-premises networks connect through Site-to-Site VPN or Direct Connect attachments on the same hub.

Each attachment has an association with a Transit Gateway route table. The route table determines what destinations the attachment can reach. You can have multiple route tables to enforce isolation between network segments, for example keeping production VPCs from routing to development VPCs even though both are attached to the same Transit Gateway.

Setting Up a Transit Gateway with Terraform

resource "aws_ec2_transit_gateway" "main" {
  description                     = "Central network hub"
  amazon_side_asn                 = 64512
  auto_accept_shared_attachments  = "disable"
  default_route_table_association = "disable"
  default_route_table_propagation = "disable"
  dns_support                     = "enable"
  vpn_ecmp_support                = "enable"

  tags = {
    Name        = "production-tgw"
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

resource "aws_ec2_transit_gateway_route_table" "production" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  tags = { Name = "production-rt" }
}

resource "aws_ec2_transit_gateway_route_table" "development" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  tags = { Name = "development-rt" }
}

resource "aws_ec2_transit_gateway_route_table" "shared_services" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  tags = { Name = "shared-services-rt" }
}

default_route_table_association = “disable” and default_route_table_propagation = “disable” are critical. Without these, every new attachment automatically joins the default route table and routes propagate between all attachments. You lose the ability to enforce isolation between network segments. Disable the defaults and manage associations explicitly.

Attaching VPCs

resource "aws_ec2_transit_gateway_vpc_attachment" "production_app" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.production_app.id
  subnet_ids         = aws_subnet.production_private[*].id

  dns_support                                     = "enable"
  transit_gateway_default_route_table_association = false
  transit_gateway_default_route_table_propagation = false

  tags = { Name = "prod-app-attachment" }
}

resource "aws_ec2_transit_gateway_vpc_attachment" "shared_services" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.shared_services.id
  subnet_ids         = aws_subnet.shared_private[*].id

  transit_gateway_default_route_table_association = false
  transit_gateway_default_route_table_propagation = false

  tags = { Name = "shared-services-attachment" }
}

resource "aws_ec2_transit_gateway_vpc_attachment" "development" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  vpc_id             = aws_vpc.development.id
  subnet_ids         = aws_subnet.dev_private[*].id

  transit_gateway_default_route_table_association = false
  transit_gateway_default_route_table_propagation = false

  tags = { Name = "development-attachment" }
}

resource "aws_ec2_transit_gateway_route_table_association" "prod_app" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.production_app.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.production.id
}

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

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

Each VPC attaches using subnets in each availability zone. Transit Gateway places an elastic network interface in each of these subnets. Traffic from resources in the VPC routes to the nearest Transit Gateway ENI, which keeps cross-AZ traffic costs down. Use your private application subnets for the attachment, not your public subnets.

Route Table Design for Network Segmentation

The route tables control what each VPC segment can reach. The goal here is to allow production and development VPCs to reach shared services, but prevent production from routing to development and vice versa.

resource "aws_ec2_transit_gateway_route_table_propagation" "shared_to_production" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.shared_services.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.production.id
}

resource "aws_ec2_transit_gateway_route_table_propagation" "shared_to_development" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.shared_services.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.development.id
}

resource "aws_ec2_transit_gateway_route_table_propagation" "prod_to_shared" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.production_app.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.shared_services.id
}

resource "aws_ec2_transit_gateway_route_table_propagation" "dev_to_shared" {
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.development.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.shared_services.id
}

resource "aws_ec2_transit_gateway_route" "blackhole_dev_in_prod" {
  destination_cidr_block         = "10.1.0.0/16"
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.production.id
  blackhole                      = true
}

Propagations tell the Transit Gateway to automatically add routes for an attachment’s CIDR ranges into a route table. By propagating the shared services attachment into both the production and development route tables, both environments can reach shared services. By not propagating production into the development route table, and adding an explicit blackhole route for the dev CIDR in the production route table, the two environments cannot communicate even if someone accidentally adds a route at the VPC level.

The blackhole route is the hard enforcement mechanism. Even if VPC route tables are misconfigured, the Transit Gateway drops packets destined for the blackholed CIDR before they leave the hub.

Updating VPC Route Tables

Attaching a VPC to the Transit Gateway is not enough. Each VPC’s route tables need entries pointing to the Transit Gateway for the destination CIDRs they need to reach.

resource "aws_route" "prod_to_shared_services" {
  count                  = length(aws_route_table.production_private)
  route_table_id         = aws_route_table.production_private[count.index].id
  destination_cidr_block = "10.2.0.0/16"
  transit_gateway_id     = aws_ec2_transit_gateway.main.id
}

resource "aws_route" "shared_to_prod" {
  count                  = length(aws_route_table.shared_private)
  route_table_id         = aws_route_table.shared_private[count.index].id
  destination_cidr_block = "10.0.0.0/16"
  transit_gateway_id     = aws_ec2_transit_gateway.main.id
}

resource "aws_route" "shared_to_dev" {
  count                  = length(aws_route_table.shared_private)
  route_table_id         = aws_route_table.shared_private[count.index].id
  destination_cidr_block = "10.1.0.0/16"
  transit_gateway_id     = aws_ec2_transit_gateway.main.id
}

A common mistake is forgetting to add the return routes. The production VPC can reach shared services because it has a route. But if the shared services VPC does not have a route back to the production CIDR, responses never arrive and connections time out. Both directions need explicit routes.

Closing Thoughts

Transit Gateway is the right architecture for any AWS environment with more than three or four VPCs that need to communicate. The hub model is operationally simpler than a peering mesh, the route table segmentation gives you hard network isolation boundaries, and adding new VPCs requires one attachment and a handful of route entries rather than a combinatorial explosion of peering connections.

Design your route tables before you start creating attachments. Decide which segments can reach each other and which cannot. Use blackhole routes as the enforcement mechanism for prohibited paths. Build it in Terraform so the network topology is visible, reviewable, and reproducible.

Enjoy the cloud.

Osama


#AWS #TransitGateway #NetworkEngineering #CloudArchitecture #VPC #CloudSecurity #Terraform #InfrastructureAsCode #AmazonWebServices #SolutionsArchitect #CloudNative #CloudComputing #NetworkDesign #TechBlog #CloudInfrastructure #HubAndSpoke #MultiVPC #DevOps #SystemDesign #CloudNetworking

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.