IaC AWS

Terraform Module: AWS Direct Connect — a reusable Direct Connect gateway with Transit Gateway association

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

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 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/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 (6451265534 or 42000000004294967294); 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

TerraformAWSDirect ConnectModuleIaC
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