Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_dx_gateway that creates a Direct Connect gateway, wires same-account VGW/Transit Gateway associations with allowed prefixes, and emits a cross-account association proposal. 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 "direct_connect" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-direct-connect?ref=v1.0.0"
name = "..." # Name of the Direct Connect gateway (1–100 chars).
amazon_side_asn = 0 # Private Amazon-side BGP ASN (`64512`–`65534` or `420000…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A Direct Connect gateway (aws_dx_gateway) is a globally-available, account-scoped object that sits between your AWS Direct Connect connection’s private virtual interfaces (VIFs) and the VPCs you want that dedicated link to reach. On its own it carries no cables and no IP space: it is a routing anchor identified by a name and a single Amazon-side BGP ASN. The actual reachability comes from associations — you attach a Direct Connect gateway to a virtual private gateway (VGW) on a specific VPC, or to a Transit Gateway that fans out to dozens of VPCs, and you advertise a set of allowed prefixes that bound which of your on-premises CIDRs are accepted over the link. One DX gateway can be associated with VGWs/TGWs in up to ten different Regions and across multiple accounts, which is exactly why it exists as a separate resource from the physical connection.
The resource itself is deceptively small — aws_dx_gateway has only name and amazon_side_asn, and notably it does not accept tags. All of the operational risk lives in the surrounding wiring: the Amazon-side ASN must not collide with the ASN you use on your customer routers or your Transit Gateway, associations to a VGW versus a TGW use different arguments, allowed_prefixes is the security boundary that decides what on-prem routes propagate, and cross-account associations require a two-step proposal → accept handshake that trips up everyone the first time. Wrapping this in a module gives every hybrid-network team one version-pinned definition where the ASN is validated, the same-account association is created in the right order, the allowed-prefix list is explicit and reviewable, and the optional cross-account proposal is emitted with the correct owner account — so nobody hand-rolls a DX gateway with a clashing ASN or an accidentally wide-open prefix list again.
When to use it
- You are terminating a Direct Connect private VIF and need a Direct Connect gateway as the routing anchor between the VIF and one or more VPCs.
- You want one dedicated link to reach many VPCs: associate the DX gateway with a Transit Gateway instead of attaching a separate VGW per VPC, and let TGW route tables fan traffic out.
- Your VPCs and your Direct Connect connection live in different Regions — a single DX gateway can associate with VGWs/TGWs across up to ten Regions, so the on-prem link reaches them all.
- You run a hub network account that owns the Direct Connect connection and need to grant other accounts’ Transit Gateways access via a cross-account association proposal.
- You need a single, auditable definition of which on-premises prefixes are allowed across the link, expressed as code and gated through PR review rather than clicked into the console.
- You do not need this for plain internet egress (that is an Internet Gateway / NAT) or for site-to-site IPsec over the public internet (that is a Site-to-Site VPN with a customer gateway) — Direct Connect is for the private, dedicated physical circuit.
Module structure
terraform-module-aws-direct-connect/
├── versions.tf # provider + Terraform version constraints
├── main.tf # aws_dx_gateway + same-account association + optional x-account proposal
├── variables.tf # var-driven inputs with ASN / prefix validation
└── outputs.tf # gateway id/ownership + association ids/state
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Create the same-account association only when the caller supplies a
# VGW or Transit Gateway id to attach the DX gateway to.
create_association = var.associated_gateway_id != null && var.associated_gateway_id != ""
# Emit a cross-account association proposal only when the associated
# gateway is owned by a different account than the DX gateway.
create_proposal = (
var.proposal_associated_gateway_id != null &&
var.proposal_associated_gateway_owner_account_id != null
)
}
# The Direct Connect gateway itself: a global routing anchor. It only takes a
# name and the Amazon-side BGP ASN, and (per the AWS provider) does not support
# tags — ownership/cost metadata is carried on the associated VGW/TGW instead.
resource "aws_dx_gateway" "this" {
name = var.name
amazon_side_asn = var.amazon_side_asn
}
# ---------------------------------------------------------------------------
# Same-account association: attach the DX gateway to a VGW or Transit Gateway
# in THIS account and advertise the on-premises prefixes allowed over the link.
# ---------------------------------------------------------------------------
resource "aws_dx_gateway_association" "this" {
count = local.create_association ? 1 : 0
dx_gateway_id = aws_dx_gateway.this.id
associated_gateway_id = var.associated_gateway_id
# The prefixes (your on-prem CIDRs) that AWS will accept/advertise over the
# private VIF. This is the security boundary — keep it tight and explicit.
allowed_prefixes = var.allowed_prefixes
timeouts {
create = var.association_create_timeout
update = var.association_update_timeout
delete = var.association_delete_timeout
}
}
# ---------------------------------------------------------------------------
# Cross-account association proposal (optional): when the VGW/TGW lives in a
# DIFFERENT account, the owner of THAT gateway runs this proposal; the owner of
# the DX gateway then accepts it (aws_dx_gateway_association_proposal +
# aws_dx_gateway_association with proposal_id on the accepting side).
# ---------------------------------------------------------------------------
resource "aws_dx_gateway_association_proposal" "this" {
count = local.create_proposal ? 1 : 0
dx_gateway_id = aws_dx_gateway.this.id
dx_gateway_owner_account_id = var.proposal_dx_gateway_owner_account_id
associated_gateway_id = var.proposal_associated_gateway_id
allowed_prefixes = var.proposal_allowed_prefixes
}
variables.tf
variable "name" {
description = "Name of the Direct Connect gateway. Shown in the console and used to identify the gateway."
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 100
error_message = "name must be between 1 and 100 characters."
}
}
variable "amazon_side_asn" {
description = "Private BGP ASN on the Amazon side of the DX gateway. Must not collide with your on-prem or Transit Gateway ASN."
type = number
validation {
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 (16-bit) or 4200000000-4294967294 (32-bit)."
}
}
# --- Same-account association ------------------------------------------------
variable "associated_gateway_id" {
description = "ID of a VGW (vgw-...) or Transit Gateway (tgw-...) in THIS account to associate. Null/empty to skip the association."
type = string
default = null
validation {
condition = (
var.associated_gateway_id == null ||
var.associated_gateway_id == "" ||
can(regex("^(vgw|tgw)-[0-9a-f]{8,17}$", var.associated_gateway_id))
)
error_message = "associated_gateway_id must be a VGW id (vgw-...) or Transit Gateway id (tgw-...)."
}
}
variable "allowed_prefixes" {
description = "On-premises IPv4/IPv6 CIDRs advertised over the link for the same-account association. The route security boundary."
type = list(string)
default = []
validation {
condition = alltrue([
for p in var.allowed_prefixes : can(cidrhost(p, 0))
])
error_message = "Every entry in allowed_prefixes must be a valid CIDR (e.g. 10.0.0.0/8 or 192.168.0.0/16)."
}
}
variable "association_create_timeout" {
description = "Timeout for creating the DX gateway association."
type = string
default = "30m"
}
variable "association_update_timeout" {
description = "Timeout for updating the DX gateway association (e.g. changing allowed_prefixes)."
type = string
default = "30m"
}
variable "association_delete_timeout" {
description = "Timeout for deleting the DX gateway association."
type = string
default = "30m"
}
# --- Cross-account association proposal --------------------------------------
variable "proposal_associated_gateway_id" {
description = "ID of a VGW/TGW in ANOTHER account to propose for association. Set together with the owner account id to emit a proposal."
type = string
default = null
validation {
condition = (
var.proposal_associated_gateway_id == null ||
can(regex("^(vgw|tgw)-[0-9a-f]{8,17}$", var.proposal_associated_gateway_id))
)
error_message = "proposal_associated_gateway_id must be a VGW id (vgw-...) or Transit Gateway id (tgw-...)."
}
}
variable "proposal_dx_gateway_owner_account_id" {
description = "12-digit AWS account ID that OWNS the DX gateway (the account that will accept the proposal)."
type = string
default = null
validation {
condition = (
var.proposal_dx_gateway_owner_account_id == null ||
can(regex("^[0-9]{12}$", var.proposal_dx_gateway_owner_account_id))
)
error_message = "proposal_dx_gateway_owner_account_id must be a 12-digit AWS account ID."
}
}
variable "proposal_associated_gateway_owner_account_id" {
description = "12-digit AWS account ID that owns the VGW/TGW being proposed. Presence (with the gateway id) enables the proposal."
type = string
default = null
validation {
condition = (
var.proposal_associated_gateway_owner_account_id == null ||
can(regex("^[0-9]{12}$", var.proposal_associated_gateway_owner_account_id))
)
error_message = "proposal_associated_gateway_owner_account_id must be a 12-digit AWS account ID."
}
}
variable "proposal_allowed_prefixes" {
description = "On-premises CIDRs to advertise for the cross-account association proposal."
type = list(string)
default = []
validation {
condition = alltrue([
for p in var.proposal_allowed_prefixes : can(cidrhost(p, 0))
])
error_message = "Every entry in proposal_allowed_prefixes must be a valid CIDR."
}
}
outputs.tf
output "id" {
description = "ID of the Direct Connect gateway."
value = aws_dx_gateway.this.id
}
output "name" {
description = "Name of the Direct Connect gateway."
value = aws_dx_gateway.this.name
}
output "amazon_side_asn" {
description = "Amazon-side BGP ASN configured on the DX gateway."
value = aws_dx_gateway.this.amazon_side_asn
}
output "owner_account_id" {
description = "AWS account ID that owns the Direct Connect gateway."
value = aws_dx_gateway.this.owner_account_id
}
output "association_id" {
description = "ID of the same-account DX gateway association, or null when no association was created."
value = try(aws_dx_gateway_association.this[0].id, null)
}
output "association_state" {
description = "State of the same-account association (e.g. associated), or null when not created."
value = try(aws_dx_gateway_association.this[0].dx_gateway_association_id, null)
}
output "associated_gateway_id" {
description = "ID of the VGW/TGW associated in this account, or null when not created."
value = try(aws_dx_gateway_association.this[0].associated_gateway_id, null)
}
output "allowed_prefixes" {
description = "Allowed prefixes advertised over the same-account association."
value = try(aws_dx_gateway_association.this[0].allowed_prefixes, [])
}
output "proposal_id" {
description = "ID of the cross-account association proposal, or null when no proposal was emitted."
value = try(aws_dx_gateway_association_proposal.this[0].id, null)
}
How to use it
# A Transit Gateway in this account that will fan the on-prem link out to many VPCs.
resource "aws_ec2_transit_gateway" "core" {
description = "kloudvin-core-tgw"
amazon_side_asn = 64513 # MUST differ from the DX gateway ASN below
default_route_table_association = "enable"
default_route_table_propagation = "enable"
tags = {
Name = "kloudvin-core-tgw"
}
}
module "direct_connect" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-direct-connect?ref=v1.0.0"
name = "kloudvin-prod-dxgw"
amazon_side_asn = 64512
# Same-account association to the Transit Gateway, advertising only the
# on-prem data-center ranges that are allowed to traverse the link.
associated_gateway_id = aws_ec2_transit_gateway.core.id
allowed_prefixes = [
"10.50.0.0/16", # primary DC
"10.51.0.0/16", # DR DC
]
}
# Downstream: a Transit Gateway route table propagation/route that uses the
# module's associated_gateway_id output, so the on-prem prefixes reachable via
# the DX gateway are routed to the workload VPC attachment.
resource "aws_ec2_transit_gateway_route" "to_on_prem" {
destination_cidr_block = "10.50.0.0/16"
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.workload.id
transit_gateway_route_table_id = aws_ec2_transit_gateway.core.association_default_route_table_id
# Implicit dependency on the DX gateway association being in place.
depends_on = [module.direct_connect]
}
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/direct_connect/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-direct-connect?ref=v1.0.0"
}
inputs = {
name = "..."
amazon_side_asn = 0
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/direct_connect && 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 of the Direct Connect gateway (1–100 chars). |
amazon_side_asn |
number |
— | Yes | Private Amazon-side BGP ASN (64512–65534 or 4200000000–4294967294); must not collide with on-prem/TGW ASN. |
associated_gateway_id |
string |
null |
No | VGW (vgw-…) or Transit Gateway (tgw-…) in this account to associate. Null/empty skips the association. |
allowed_prefixes |
list(string) |
[] |
No | On-prem CIDRs advertised over the same-account association; each validated as a CIDR. |
association_create_timeout |
string |
"30m" |
No | Create timeout for the same-account association. |
association_update_timeout |
string |
"30m" |
No | Update timeout for the same-account association. |
association_delete_timeout |
string |
"30m" |
No | Delete timeout for the same-account association. |
proposal_associated_gateway_id |
string |
null |
No | VGW/TGW in another account to propose for association. |
proposal_dx_gateway_owner_account_id |
string |
null |
No | 12-digit account ID that owns the DX gateway (accepts the proposal). |
proposal_associated_gateway_owner_account_id |
string |
null |
No | 12-digit account ID that owns the proposed VGW/TGW; enables the proposal. |
proposal_allowed_prefixes |
list(string) |
[] |
No | On-prem CIDRs advertised for the cross-account proposal; each validated as a CIDR. |
Outputs
| Name | Description |
|---|---|
id |
ID of the Direct Connect gateway. |
name |
Name of the Direct Connect gateway. |
amazon_side_asn |
Amazon-side BGP ASN configured on the gateway. |
owner_account_id |
AWS account ID that owns the Direct Connect gateway. |
association_id |
ID of the same-account association, or null when not created. |
association_state |
Association identifier/state for the same-account association, or null. |
associated_gateway_id |
ID of the VGW/TGW associated in this account, or null. |
allowed_prefixes |
Allowed prefixes advertised over the same-account association. |
proposal_id |
ID of the cross-account association proposal, or null when not emitted. |
Enterprise scenario
A manufacturing group runs a centralized network hub account that owns the physical Direct Connect circuits from two colocation facilities into ap-south-1. The platform team uses this module to create one Direct Connect gateway with ASN 64512 and associate it to the hub’s Transit Gateway, advertising only the corporate 10.50.0.0/16 and 10.51.0.0/16 data-center ranges via allowed_prefixes. When a newly-onboarded business-unit account needs the on-prem link, they don’t get their own circuit — instead the module emits a cross-account association proposal for that unit’s Transit Gateway, and the hub team accepts it, so the dedicated link is shared cleanly across the Organization with the prefix list as the single, code-reviewed access boundary.
Best practices
- Keep the three ASNs distinct. The
amazon_side_asnon the DX gateway, your Transit Gateway’samazon_side_asn, and your on-premises router ASN must not overlap, or BGP sessions will be rejected or routes will loop. The module validates that the DX ASN is in a private range, but you own the no-collision rule across the design. - Treat
allowed_prefixesas a firewall, not a convenience. This list is the only thing bounding which on-prem routes propagate over the link; never use a broad0.0.0.0/0here, scope it to the exact data-center CIDRs, and require PR review on every change since widening it changes what your VPC fleet can reach on-prem. - Prefer Transit Gateway association over per-VPC VGWs. A DX gateway associated with a single Transit Gateway reaches your whole VPC estate through TGW route tables; attaching a separate VGW per VPC multiplies associations, complicates routing, and hits limits far sooner.
- Use the proposal/accept handshake for cross-account, not shared credentials. When the VGW/TGW lives in another account, drive it through
aws_dx_gateway_association_proposaland have the DX gateway owner accept — this keeps a clean, auditable ownership boundary instead of sharing IAM access across accounts. - Tag for cost and ownership on the associated resources.
aws_dx_gatewayitself accepts no tags, so putCostCenter/Owner/Environmenton the Transit Gateway, VGW, VIF, and the underlying connection; Direct Connect data-transfer-out and port-hours are billed on the connection, so cost allocation must hang off those resources. - Build for resilience and plan ASN/Region reuse. A single DX gateway can span up to ten Regions and many associations — reuse one gateway across Regions rather than minting new ones, and for an SLA-grade link provision the underlying Direct Connect on redundant ports/locations (with a Site-to-Site VPN backup) since the gateway only routes, it does not add physical redundancy.