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
- You run a hub-and-spoke topology across more than a handful of VPCs and VPC peering has become unmanageable.
- You have a multi-account AWS Organization and want a central network account to own the TGW and share it (via RAM) to spoke accounts.
- You need hybrid connectivity — terminating Site-to-Site VPN (optionally ECMP across multiple tunnels) or Direct Connect through one hub.
- You want segmentation: separate route tables for prod / non-prod / shared-services so workloads cannot route to each other by default.
- Skip a TGW (or a dedicated module) for two or three VPCs that only need point-to-point reachability — plain VPC peering is cheaper, since TGW bills per-attachment-hour and per-GB of data processed.
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 config — live/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 config — live/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
- Lock the ASN before you ship.
amazon_side_asncannot be changed in place — a change forces TGW replacement and tears down every attachment. Pick a private ASN that does not clash with your on-prem/DXGW ASNs and treat it as permanent. - Disable default-route-table auto-behaviour for segmented networks. Set both
default_route_table_associationanddefault_route_table_propagationtofalse, then drive routing through explicitaws_ec2_transit_gateway_route_tableresources so environments are isolated by design rather than by accident. - Keep
auto_accept_shared_attachments = falsein regulated accounts. An approval gate on cross-account attachments stops an arbitrary spoke from joining the prod hub; flip it totrueonly when the RAM share is already scoped tightly to a trusted OU. - Share by OU or Org ARN, not loose account IDs. Set
ram_allow_external_principals = falseand pointram_principalsat an Organization/OU ARN so new accounts inherit access automatically and you never share outside the boundary. - Mind the bill. TGW charges per attachment-hour plus per-GB processed, so consolidate VPCs behind a shared-services attachment where possible and prefer TGW over a full mesh of paid VPC peering only when the attachment count justifies it.
- Name and tag for the hub topology. Encode role, environment, and region in
name(e.g.hub-prod-euw1) and propagateEnvironment/CostCenterviatagsso cross-account cost allocation and route-table audits stay legible.