IaC AWS

Terraform Module: AWS Transit Gateway — one hub for every VPC and on-prem link

Quick take — Build a reusable Terraform module for AWS Transit Gateway with hashicorp/aws ~> 5.0: var-driven ASN, DNS/ECMP/multicast support, default route table association/propagation toggles, and RAM sharing across accounts. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "transit_gateway" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-transit-gateway?ref=v1.0.0"

  name = "..."  # Name tag for the TGW and RAM share (e.g. `hub-prod-euw1…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An AWS Transit Gateway (TGW) is a regional network transit hub that connects VPCs, VPN tunnels, Direct Connect gateways, and even peered Transit Gateways in other regions through a single, horizontally-scaled router. Instead of a tangle of N×(N-1)/2 VPC peering connections, every attachment talks to one TGW and routing is governed by Transit Gateway route tables. It is the backbone most multi-account AWS landing zones standardise on for east-west and hybrid connectivity.

The raw aws_ec2_transit_gateway resource exposes a dozen knobs — Amazon-side ASN for BGP, DNS support, ECMP for multipath VPN, multicast, the automatic default-route-table association and propagation flags, and whether the TGW auto-accepts cross-account attachment requests. Getting those defaults wrong is painful to fix later because the BGP ASN is immutable and the auto-association behaviour silently shapes how every future attachment routes. Wrapping the resource in a module pins one opinionated, reviewed configuration, attaches a consistent tag set, and (optionally) shares the gateway to your organisation via AWS RAM in the same apply — so a platform team can hand out one source = line instead of a runbook.

When to use it

Module structure

terraform-module-aws-transit-gateway/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # aws_ec2_transit_gateway + optional RAM share
├── variables.tf     # all inputs with validation
└── outputs.tf       # ids + association/propagation route table ids
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  base_tags = merge(
    var.tags,
    { Name = var.name }
  )

  # RAM principals only make sense when sharing is enabled.
  ram_principals = var.enable_ram_share ? toset(var.ram_principals) : toset([])
}

resource "aws_ec2_transit_gateway" "this" {
  description = coalesce(var.description, "Transit Gateway: ${var.name}")

  # BGP ASN for the Amazon side of any VPN/DXGW attachment. Immutable.
  amazon_side_asn = var.amazon_side_asn

  # Auto-config of the default TGW route table. We expose both so segmented
  # designs can disable them and manage route tables explicitly.
  default_route_table_association = var.default_route_table_association ? "enable" : "disable"
  default_route_table_propagation = var.default_route_table_propagation ? "enable" : "disable"

  auto_accept_shared_attachments  = var.auto_accept_shared_attachments ? "enable" : "disable"
  dns_support                     = var.dns_support ? "enable" : "disable"
  vpn_ecmp_support                = var.vpn_ecmp_support ? "enable" : "disable"
  multicast_support               = var.multicast_support ? "enable" : "disable"

  # Encryption between TGW attachments (e.g. cross-region peering). null lets
  # AWS pick the account/region default rather than forcing a value.
  transit_gateway_cidr_blocks = var.transit_gateway_cidr_blocks

  tags = local.base_tags
}

# ---------------------------------------------------------------------------
# Optional: share the Transit Gateway across accounts via AWS RAM.
# ---------------------------------------------------------------------------
resource "aws_ram_resource_share" "this" {
  count = var.enable_ram_share ? 1 : 0

  name                      = "${var.name}-tgw-share"
  allow_external_principals = var.ram_allow_external_principals

  tags = local.base_tags
}

resource "aws_ram_resource_association" "this" {
  count = var.enable_ram_share ? 1 : 0

  resource_arn       = aws_ec2_transit_gateway.this.arn
  resource_share_arn = aws_ram_resource_share.this[0].arn
}

resource "aws_ram_principal_association" "this" {
  for_each = local.ram_principals

  principal          = each.value
  resource_share_arn = aws_ram_resource_share.this[0].arn
}
# variables.tf

variable "name" {
  type        = string
  description = "Name tag applied to the Transit Gateway and RAM share (e.g. \"hub-prod-euw1\")."

  validation {
    condition     = can(regex("^[A-Za-z0-9._-]{1,128}$", var.name))
    error_message = "name must be 1-128 chars of letters, digits, dot, dash or underscore."
  }
}

variable "description" {
  type        = string
  description = "Free-text description. Defaults to \"Transit Gateway: <name>\" when null."
  default     = null
}

variable "amazon_side_asn" {
  type        = number
  description = "Private ASN for the Amazon side of the BGP session (VPN/DXGW). IMMUTABLE after create."
  default     = 64512

  validation {
    # Valid private ranges: 64512-65534 (16-bit) and 4200000000-4294967294 (32-bit).
    condition = (
      (var.amazon_side_asn >= 64512 && var.amazon_side_asn <= 65534) ||
      (var.amazon_side_asn >= 4200000000 && var.amazon_side_asn <= 4294967294)
    )
    error_message = "amazon_side_asn must be a private ASN: 64512-65534 or 4200000000-4294967294."
  }
}

variable "default_route_table_association" {
  type        = bool
  description = "Automatically associate new attachments with the default route table."
  default     = true
}

variable "default_route_table_propagation" {
  type        = bool
  description = "Automatically propagate new attachment routes into the default route table."
  default     = true
}

variable "auto_accept_shared_attachments" {
  type        = bool
  description = "Auto-accept cross-account attachment requests. Leave false for an approval gate."
  default     = false
}

variable "dns_support" {
  type        = bool
  description = "Enable DNS resolution across attachments (required for cross-VPC private DNS)."
  default     = true
}

variable "vpn_ecmp_support" {
  type        = bool
  description = "Enable equal-cost multipath routing across multiple VPN tunnels."
  default     = true
}

variable "multicast_support" {
  type        = bool
  description = "Enable multicast on the Transit Gateway. Cannot be combined with vpn_ecmp_support."
  default     = false

  validation {
    condition     = !(var.multicast_support && var.vpn_ecmp_support)
    error_message = "multicast_support and vpn_ecmp_support are mutually exclusive; disable one."
  }
}

variable "transit_gateway_cidr_blocks" {
  type        = list(string)
  description = "Optional CIDR blocks (IPv4/IPv6) for the TGW, used by Connect attachments / multicast."
  default     = []

  validation {
    condition     = alltrue([for c in var.transit_gateway_cidr_blocks : can(cidrhost(c, 0))])
    error_message = "Each entry in transit_gateway_cidr_blocks must be a valid CIDR block."
  }
}

variable "enable_ram_share" {
  type        = bool
  description = "Create an AWS RAM share so other accounts can attach to this Transit Gateway."
  default     = false
}

variable "ram_allow_external_principals" {
  type        = bool
  description = "Allow sharing with principals outside your AWS Organization."
  default     = false
}

variable "ram_principals" {
  type        = list(string)
  description = "Principals to share with: account IDs, OU ARNs, or the Organization ARN."
  default     = []
}

variable "tags" {
  type        = map(string)
  description = "Additional tags merged onto all created resources."
  default     = {}
}
# outputs.tf

output "transit_gateway_id" {
  description = "ID of the Transit Gateway (use for attachments)."
  value       = aws_ec2_transit_gateway.this.id
}

output "transit_gateway_arn" {
  description = "ARN of the Transit Gateway."
  value       = aws_ec2_transit_gateway.this.arn
}

output "name" {
  description = "Name tag of the Transit Gateway."
  value       = var.name
}

output "amazon_side_asn" {
  description = "ASN configured for the Amazon side of BGP sessions."
  value       = aws_ec2_transit_gateway.this.amazon_side_asn
}

output "association_default_route_table_id" {
  description = "ID of the default association route table created with the TGW."
  value       = aws_ec2_transit_gateway.this.association_default_route_table_id
}

output "propagation_default_route_table_id" {
  description = "ID of the default propagation route table created with the TGW."
  value       = aws_ec2_transit_gateway.this.propagation_default_route_table_id
}

output "owner_id" {
  description = "AWS account ID that owns the Transit Gateway."
  value       = aws_ec2_transit_gateway.this.owner_id
}

output "ram_resource_share_arn" {
  description = "ARN of the RAM resource share, or null when sharing is disabled."
  value       = var.enable_ram_share ? aws_ram_resource_share.this[0].arn : null
}

How to use it

module "transit_gateway" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-transit-gateway?ref=v1.0.0"

  name            = "hub-prod-euw1"
  description     = "Production hub for shared-services connectivity (eu-west-1)"
  amazon_side_asn = 64512

  # Segmented design: turn off auto-association/propagation so we can drive
  # routing through explicit route tables per environment.
  default_route_table_association = false
  default_route_table_propagation = false

  dns_support      = true
  vpn_ecmp_support = true

  # Share to the whole Organization so spoke accounts can self-attach.
  enable_ram_share              = true
  ram_allow_external_principals = false
  ram_principals                = [data.aws_organizations_organization.current.arn]

  tags = {
    Environment = "prod"
    Team        = "platform-networking"
    CostCenter  = "netops"
  }
}

data "aws_organizations_organization" "current" {}

# Downstream: attach a spoke VPC to the hub using the module's output.
resource "aws_ec2_transit_gateway_vpc_attachment" "app" {
  transit_gateway_id = module.transit_gateway.transit_gateway_id
  vpc_id             = aws_vpc.app.id
  subnet_ids         = aws_subnet.tgw[*].id

  # We manage routing explicitly, so opt out of the default route table here too.
  transit_gateway_default_route_table_association = false
  transit_gateway_default_route_table_propagation = false

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

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/transit_gateway/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-transit-gateway?ref=v1.0.0"
}

inputs = {
  name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/transit_gateway && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Name tag for the TGW and RAM share (e.g. hub-prod-euw1).
description string null No Free-text description; defaults to Transit Gateway: <name>.
amazon_side_asn number 64512 No Private BGP ASN for the Amazon side. Immutable after create.
default_route_table_association bool true No Auto-associate new attachments with the default route table.
default_route_table_propagation bool true No Auto-propagate attachment routes into the default route table.
auto_accept_shared_attachments bool false No Auto-accept cross-account attachment requests.
dns_support bool true No Enable DNS resolution across attachments.
vpn_ecmp_support bool true No Enable ECMP across multiple VPN tunnels.
multicast_support bool false No Enable multicast (mutually exclusive with vpn_ecmp_support).
transit_gateway_cidr_blocks list(string) [] No CIDR blocks for Connect attachments / multicast sources.
enable_ram_share bool false No Create a RAM share for cross-account attachments.
ram_allow_external_principals bool false No Allow sharing outside the AWS Organization.
ram_principals list(string) [] No Account IDs, OU ARNs, or Org ARN to share with.
tags map(string) {} No Additional tags merged onto all resources.

Outputs

Name Description
transit_gateway_id ID of the Transit Gateway, used when creating attachments.
transit_gateway_arn ARN of the Transit Gateway.
name Name tag of the Transit Gateway.
amazon_side_asn ASN configured for the Amazon side of BGP sessions.
association_default_route_table_id ID of the default association route table.
propagation_default_route_table_id ID of the default propagation route table.
owner_id AWS account ID that owns the Transit Gateway.
ram_resource_share_arn ARN of the RAM share, or null when sharing is disabled.

Enterprise scenario

A retail group runs a central network account that owns one TGW per region (hub-prod-euw1, hub-prod-use1). The module is deployed with default_route_table_association = false and enable_ram_share = true against the Organization ARN, so each of the 40+ spoke accounts can create a aws_ec2_transit_gateway_vpc_attachment against the shared gateway without raising a ticket. The platform team then binds attachments to purpose-built route tables — prod, non-prod, and shared-services — so PCI workloads in prod can reach the shared logging VPC but never the dev environments, and Site-to-Site VPN to the stores rides ECMP across dual tunnels for resilience.

Best practices

TerraformAWSTransit GatewayModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading