Skip to content

microsoftexpert/tf-mod-aws-vpn

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🟧 AWS Site-to-Site VPN Terraform Module

Provisions a complete IPsec Site-to-Site VPN — the on-prem customer gateway, an optional Virtual Private Gateway (VGW) + VPC attachment, the keystone two-tunnel connection, static routes, and VPC route propagation — hardened to an IKEv2 / AES-GCM baseline and secret-safe by default, from one module call. Built for the AWS provider v6.x.

Terraform aws module type resources


🧩 Overview

  • 🔗 One hybrid link, fully wired. Creates aws_vpn_connection (the keystone) plus everything that is meaningless without it: the aws_customer_gateway describing the on-prem device, an optional aws_vpn_gateway + aws_vpn_gateway_attachment, optional static aws_vpn_connection_route entries, and optional aws_vpn_gateway_route_propagation into VPC route tables.
  • 🧭 Two termination paths, one validated schema. Terminate on a Virtual Private Gateway attached to a VPC (vpc_id) or directly on a Transit Gateway (transit_gateway_id) — validation enforces exactly one, never both and never neither.
  • 🔐 Hardened IPsec by default. Both tunnels default to an PII/privacy-regulation-aligned IKEv2-only baseline: strong Diffie-Hellman groups (≥ 14, weak group 2 excluded), AES-256 / AES-256-GCM encryption, and SHA-2 integrity (SHA-1 excluded). IKEv1 is off unless a device forces an exception.
  • 🤫 Secret-safe by default. Pre-shared keys, tunnel inside addresses, and the generated device-config XML are surfaced only as sensitive outputs — never plain. PSKs are AWS-generated unless you supply your own, and preshared_key_storage = "SecretsManager" keeps them out of state in plain text.
  • 🛡️ HA by construction. Every connection ships two tunnels on distinct inside CIDRs from 169.254.0.0/16 for automatic failover — AWS keeps them distinct when both are left null.
  • 🪵 Tunnel logging available. Per-tunnel CloudWatch logging (IKE + BGP) is exposed via tunnel1.log_options / tunnel2.log_options, off by default and wired to a tf-mod-aws-cloudwatch-log-group ARN when you turn it on.
  • 🧱 Deeply-typed, key-addressed children. Static routes and route propagations are first-class set(string) inputs; tunnel options are a deep object(...) with baked-in hardened defaults and validation {} on every enum and closed set.
  • 🏷️ Tags everywhere taggable. var.tags merges with provider default_tags and flows to the connection, customer gateway, and VGW; tags_all is surfaced as an output. (The VGW attachment, static routes, and route propagations are not taggable — AWS limitation.)

💡 Why it matters: A Site-to-Site VPN is the cryptographic seam between our on-prem network and AWS — PII rides it. A single secure-by-default module keeps the IKE/IPsec posture strong, the pre-shared keys out of logs, and the HA tunnel pair consistent, so the blast radius of a weak cipher suite or a leaked PSK is closed by default, not by review.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

tf-mod-aws-vpn is a networking module — it consumes a termination target (a VPC for the VGW path or a Transit Gateway for the TGW path), an optional certificate for a cert-based customer gateway, an optional DX transport attachment for a private VPN, and an optional log group; downstream it feeds monitoring and a Secrets Manager secret when PSK storage is delegated there.

flowchart LR
 vpc["tf-mod-aws-vpc<br/>vpc_id + route_table_ids"]
 tgw["tf-mod-aws-transit-gateway<br/>transit_gateway_id"]
 dx["tf-mod-aws-dx<br/>transport TGW attachment (PrivateIpv4)"]
 acm["tf-mod-aws-acm / Private CA<br/>customer_gateway_certificate_arn"]
 cwl["tf-mod-aws-cloudwatch-log-group<br/>tunnel log_group_arn"]
 vpn["tf-mod-aws-vpn"]
 sm["tf-mod-aws-secrets-manager<br/>preshared_key_arn"]
 cw["CloudWatch / monitoring<br/>tunnel state + metrics"]

 vpc -->|"vpc_id + route_table_ids"| vpn
 tgw -->|"transit_gateway_id"| vpn
 dx -->|"transport_transit_gateway_attachment_id"| vpn
 acm -->|"customer_gateway_certificate_arn"| vpn
 cwl -->|"log_group_arn"| vpn
 vpn -->|"preshared_key_arn"| sm
 vpn -->|"id / arn"| cw

 style vpn fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
Loading

🧬 What this module builds

flowchart TD
 subgraph mod["tf-mod-aws-vpn"]
 cgw["aws_customer_gateway.this<br/>on-prem device (always)"]
 vgw["aws_vpn_gateway.this<br/>VGW path only"]
 att["aws_vpn_gateway_attachment.this<br/>VGW to VPC"]
 vpn["aws_vpn_connection.this<br/>(keystone) two-tunnel IPsec"]
 rte["aws_vpn_connection_route.this<br/>for_each routes (static)"]
 prop["aws_vpn_gateway_route_propagation.this<br/>for_each route_table_ids (VGW)"]
 end

 cgw --> vpn
 vgw --> att
 att --> vpn
 vgw -.-> prop
 vpn --> rte

 style vpn fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
 style vgw stroke-dasharray: 5 5
 style att stroke-dasharray: 5 5
 style rte stroke-dasharray: 5 5
 style prop stroke-dasharray: 5 5
Loading
Resource Count Created when
aws_customer_gateway.this 1 always (the on-prem device)
aws_vpn_connection.this 1 always (keystone)
aws_vpn_gateway.this 0..1 VGW path only (vpc_id set)
aws_vpn_gateway_attachment.this 0..1 VGW path only (vpc_id set)
aws_vpn_connection_route.this 0..N one per routes entry (static routing)
aws_vpn_gateway_route_propagation.this 0..N one per route_table_ids entry (VGW path)

The VGW path (vpc_id) creates the VGW, attaches it to the VPC, terminates the connection on it, and may propagate VPN routes into VPC route tables. The TGW path (transit_gateway_id) attaches the connection directly to the Transit Gateway — no VGW, no attachment, no route propagation.


✅ Provider / Versions

Requirement Version
Terraform >= 1.12.0
hashicorp/aws >= 6.0, < 7.0

The module declares only a required_providers block (providers.tf) and inherits the single aws provider — there is no provider {} block, no region variable, and no credential variable. Credentials resolve through the standard AWS chain at the root/pipeline level (env vars → SSO/shared credentials → assume_role → instance profile / IRSA → OIDC web identity). The caller chooses the Region by which provider configuration it passes into the aws slot.

ℹ️ Site-to-Site VPN is a regional service — there is no us-east-1 global constraint. The VPN, its VGW/customer gateway, and any Transit Gateway must all live in the same Region as the configured provider.


🔑 Required IAM Permissions

Least-privilege actions the Terraform execution identity needs to manage this module.

Action Required for Notes
ec2:CreateCustomerGateway, ec2:DeleteCustomerGateway, ec2:DescribeCustomerGateways Customer gateway lifecycle Always — the on-prem device definition
ec2:CreateVpnGateway, ec2:DeleteVpnGateway, ec2:DescribeVpnGateways Virtual Private Gateway lifecycle VGW path only (vpc_id set)
ec2:AttachVpnGateway, ec2:DetachVpnGateway VGW ↔ VPC attachment VGW path only
ec2:CreateVpnConnection, ec2:DeleteVpnConnection, ec2:DescribeVpnConnections, ec2:ModifyVpnConnection, ec2:ModifyVpnConnectionOptions VPN connection lifecycle + options Core CRUD on the keystone
ec2:ModifyVpnTunnelOptions, ec2:ModifyVpnTunnelCertificate Per-tunnel IKE/DH/Phase hardening Applies the hardened tunnel1/tunnel2 baseline
ec2:CreateVpnConnectionRoute, ec2:DeleteVpnConnectionRoute Static routes Only when routes set (static_routes_only = true)
ec2:EnableVgwRoutePropagation, ec2:DisableVgwRoutePropagation Route propagation into VPC route tables VGW path only, when route_table_ids set
ec2:CreateTags, ec2:DeleteTags Tagging Connection, customer gateway, VGW (on create/delete)
iam:CreateServiceLinkedRole First Site-to-Site VPN connection Creates AWSServiceRoleForVPCS2SVPNInternal if absent (idempotent)
acm:DescribeCertificate Certificate-based customer gateway Only when customer_gateway_certificate_arn set
secretsmanager:CreateSecret, secretsmanager:TagResource preshared_key_storage = "SecretsManager" Performed by the VPN service principal; the secret surfaces via preshared_key_arn

🔒 Service-linked role. AWSServiceRoleForVPCS2SVPNInternal is auto-created on the first Site-to-Site VPN connection in the account; the identity needs iam:CreateServiceLinkedRole only until it exists. The Secrets Manager actions are exercised by the VPN service principal (not your identity directly) when PSK storage is delegated to Secrets Manager. The on-prem appliance is configured out-of-band from the generated customer_gateway_configuration — no IAM is involved on that side.


📋 AWS Prerequisites

  • Service-linked role. AWSServiceRoleForVPCS2SVPNInternal must exist (auto-created on first connection; the identity may need iam:CreateServiceLinkedRole).
  • A termination target must exist — set exactly one:
  • VGW path: a VPC to attach to. Wire vpc_id from tf-mod-aws-vpc. The module creates and attaches the VGW for you.
  • TGW path: an existing Transit Gateway. Wire transit_gateway_id from tf-mod-aws-transit-gateway. The connection attaches directly; no VGW is created.
  • On-prem device. A publicly routable customer-gateway IPv4 address (customer_gateway_ip_address) and, for dynamic routing, a BGP ASN (customer_gateway_bgp_asn). A certificate-based customer gateway (customer_gateway_certificate_arn) may omit the public IP. The downloadable device configuration must be applied to the appliance — out of scope here.
  • Routing. Static-routing connections (static_routes_only = true) require explicit routes entries; dynamic (BGP) connections advertise routes automatically. The VGW path may propagate routes into VPC route tables via route_table_ids.
  • TGW-only features. enable_acceleration (Global Accelerator), tunnel_bandwidth (large = 5 Gbps), and tunnel_inside_ip_version = "ipv6" are supported only on the Transit Gateway path — validation/gating enforces this.
  • Private VPN over Direct Connect. outside_ip_address_type = "PrivateIpv4" additionally requires transport_transit_gateway_attachment_id from a TGW ↔ DX-gateway attachment (tf-mod-aws-dx).
  • Quotas (per AWS Site-to-Site VPN quotas; Region-specific, raisable via Service Quotas):
  • 10 Site-to-Site VPN connections per VGW (and per TGW), soft.
  • 50 customer gateways per Region.
  • 2 tunnels per connection (fixed — the HA pair).
  • ECMP across multiple connections is available on the TGW path for aggregate throughput.

📁 Module Structure

tf-mod-aws-vpn/
├── providers.tf # required_providers (aws >= 6.0, < 7.0); no provider block, no region var
├── variables.tf # termination path → customer gateway → VGW config → connection config → hardened tunnel1/tunnel2 → PSKs (sensitive) → routes → propagation → tags
├── main.tf # aws_vpn_connection.this (keystone) + customer_gateway + vpn_gateway/attachment + route/propagation for_each
├── outputs.tf # id + arn (+ vpn_connection_id) + customer_gateway/vgw ids + tunnel endpoints (sensitive) + tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM permissions, prerequisites, gotchas

⚙️ Quick Start

Smallest working call — a dynamic-routing VPN terminating on a VGW attached to an upstream VPC, with hardened tunnels and AWS-generated PSKs:

module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  # VGW termination path — attach to the VPC from tf-mod-aws-vpc
  vpc_id = module.vpc.vpc_id

  # on-prem device (owned by the network team)
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  # propagate learned routes into the private route tables
  route_table_ids = toset(module.vpc.private_route_table_ids)

  # tunnels default to the hardened IKEv2 / AES-GCM baseline; PSKs AWS-generated
  tags = { Environment = "prod", DataClass = "internal" }
}

⚠️ Set exactly one of vpc_id (VGW path) or transit_gateway_id (TGW path) — validation rejects both or neither.


🔌 Cross-Module Contract

Consumes

Input Type Source module
vpc_id string (VPC id) tf-mod-aws-vpc (VGW path)
transit_gateway_id string (TGW id) tf-mod-aws-transit-gateway (TGW path)
route_table_ids set(string) (route table ids) tf-mod-aws-vpc (route propagation, VGW path)
customer_gateway_certificate_arn string (ACM / Private CA ARN) tf-mod-aws-acm / AWS Private CA (cert-based CGW)
transport_transit_gateway_attachment_id string (TGW↔DX attachment id) tf-mod-aws-dx (private VPN over Direct Connect)
tunnel1.log_options...log_group_arn / tunnel2... string (log group ARN) tf-mod-aws-cloudwatch-log-group (tunnel logging)
customer_gateway_ip_address / customer_gateway_bgp_asn string / number on-prem network team

Emits

Output Description Consumed by
id / vpn_connection_id VPN connection id (vpn-...) monitoring, route references
arn VPN connection ARN — the cross-resource reference type IAM/policy references, tagging governance
transit_gateway_attachment_id TGW VPN attachment id (TGW path; null on VGW path) tf-mod-aws-transit-gateway route tables, attachment associations
customer_gateway_id / customer_gateway_arn Customer gateway id / ARN reference, audit
vpn_gateway_id / vpn_gateway_arn VGW id / ARN (VGW path; null on TGW path) route propagation, DX private VIFs
tunnel1_address / tunnel2_address Tunnel outside (public) IPs on-prem device configuration
tunnel1_bgp_asn / tunnel2_bgp_asn Amazon-side BGP ASN per tunnel on-prem BGP configuration
tunnel1_cgw_inside_address / tunnel2_cgw_inside_address Tunnel inside CGW addresses — SENSITIVE on-prem device config (sensitive)
tunnel1_vgw_inside_address / tunnel2_vgw_inside_address Tunnel inside AWS-side addresses — SENSITIVE on-prem device config (sensitive)
tunnel1_preshared_key / tunnel2_preshared_key Tunnel PSKs — SENSITIVE on-prem device config (sensitive)
customer_gateway_configuration Generated native device-config XML — SENSITIVE (contains PSKs) on-prem appliance bootstrap
preshared_key_arn Secrets Manager secret ARN for PSKs (when SecretsManager storage) tf-mod-aws-secrets-manager reference, rotation
route_destination_cidrs Map of static route key → destination CIDR audit
propagated_route_table_ids Route table ids receiving propagated routes (VGW path) audit
tags_all All tags incl. provider default_tags (resource tags win) governance/audit

📚 Example Library

1 · Minimal VGW path, dynamic BGP routing (default secure posture)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  route_table_ids = toset(module.vpc.private_route_table_ids)
}
2 · Transit Gateway path (attach to the hub)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  # TGW termination — no VGW is created
  transit_gateway_id          = module.tgw.id # from tf-mod-aws-transit-gateway
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010
}

# the connection's TGW attachment, for TGW route-table association upstream
# module.vpn.transit_gateway_attachment_id
3 · Static-routing connection (on-prem device without BGP)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"

  # static routing requires explicit on-prem destination CIDRs
  static_routes_only = true
  routes             = ["10.50.0.0/16", "10.51.0.0/16"]

  route_table_ids = toset(module.vpc.private_route_table_ids)
}
4 · Certificate-based customer gateway (no public IP)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id = module.vpc.vpc_id

  # cert-based CGW — ip_address may be omitted
  customer_gateway_certificate_arn = module.private_ca_cert.arn # ACM / Private CA
  customer_gateway_bgp_asn         = 65010
  customer_gateway_device_name     = "datacenter-firewall-01"
}
5 · Per-tunnel CloudWatch logging on (IKE + BGP)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  tunnel1 = {
    log_options = {
      cloudwatch_log_options = {
        log_enabled       = true
        log_group_arn     = module.vpn_logs.arn # from tf-mod-aws-cloudwatch-log-group
        log_output_format = "json"
        bgp_log_enabled   = true
        bgp_log_group_arn = module.vpn_logs.arn
      }
    }
  }
  tunnel2 = {
    log_options = {
      cloudwatch_log_options = {
        log_enabled   = true
        log_group_arn = module.vpn_logs.arn
      }
    }
  }
}
6 · Pin tunnel inside CIDRs (deterministic 169.254/16 addressing)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  tunnel1 = { inside_cidr = "169.254.10.0/30" }
  tunnel2 = { inside_cidr = "169.254.11.0/30" } # keep the two distinct
}
7 · Narrowed IPsec security associations (constrained local/remote CIDRs)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  # constrain the SA instead of the default 0.0.0.0/0 ↔ 0.0.0.0/0
  local_ipv4_network_cidr  = "10.50.0.0/16"        # on-prem supernet
  remote_ipv4_network_cidr = module.vpc.cidr_block # AWS side
}
8 · PSKs in Secrets Manager (keep keys out of plain state)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  # the VPN service stores the tunnel PSKs in Secrets Manager
  preshared_key_storage = "SecretsManager"
}

# reference the managed secret downstream
# module.vpn.preshared_key_arn -> tf-mod-aws-secrets-manager / rotation
9 · Large-bandwidth IPv6 tunnels (TGW path only)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  transit_gateway_id          = module.tgw.id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  # both features are TGW-only and validated as such
  tunnel_bandwidth         = "large" # up to 5 Gbps per tunnel
  tunnel_inside_ip_version = "ipv6"
}
10 · Accelerated VPN with Global Accelerator (TGW path only)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  transit_gateway_id          = module.tgw.id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  enable_acceleration = true # FORCE-NEW; not compatible with tunnel_bandwidth or the VGW path
}
11 · Private VPN over Direct Connect (PrivateIpv4)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  transit_gateway_id          = module.tgw.id
  customer_gateway_ip_address = "10.200.0.10" # private CGW address
  customer_gateway_bgp_asn    = 65010

  outside_ip_address_type                 = "PrivateIpv4"
  transport_transit_gateway_attachment_id = module.dx.transit_gateway_attachment_id # from tf-mod-aws-dx
}
12 · 32-bit (4-byte) BGP ASN on the customer gateway
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"

  # use the extended ASN; set the 16/32-bit one to null
  customer_gateway_bgp_asn          = null
  customer_gateway_bgp_asn_extended = 4200000123
}
13 · Supply your own PSK (device requires a fixed key)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  # SENSITIVE — pass from a secret data source, never a literal in VCS.
  # 8-64 chars, alphanumeric +. _, must not start with 0.
  tunnel1_preshared_key = var.tunnel1_psk
  tunnel2_preshared_key = var.tunnel2_psk
}
14 · Tags (merge with provider default_tags)
# Caller's provider block owns default_tags; the module never sets it.
provider "aws" {
  default_tags { tags = { Owner = "platform", ManagedBy = "terraform" } }
}

module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  tags = {
    Environment = "prod" # resource tag — wins over default_tags on key conflict
    DataClass   = "internal"
  }
}

# module.vpn.tags_all == { Owner, ManagedBy, Environment, DataClass }
# applied to the connection, customer gateway, and VGW (the attachment/routes are not taggable)
15 · Secure-default opt-out — relaxed tunnel crypto (documented exception)
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  vpc_id                      = module.vpc.vpc_id
  customer_gateway_ip_address = "203.0.113.10"
  customer_gateway_bgp_asn    = 65010

  # OPT-OUT: only for a legacy device that cannot do the hardened baseline.
  # Each relaxation widens the crypto attack surface — document the exception.
  tunnel1 = {
    ike_versions                 = ["ikev1", "ikev2"] # re-enable IKEv1 (discouraged)
    phase1_dh_group_numbers      = [14, 15, 16]       # narrower than the 14-24 default
    phase1_encryption_algorithms = ["AES256"]         # drop GCM
    phase1_integrity_algorithms  = ["SHA2-256"]
  }
  tunnel2 = {
    ike_versions                 = ["ikev1", "ikev2"]
    phase1_dh_group_numbers      = [14, 15, 16]
    phase1_encryption_algorithms = ["AES256"]
    phase1_integrity_algorithms  = ["SHA2-256"]
  }
}
16 · End-to-end composition — VPC + TGW + log group + VPN (mandatory finale)
provider "aws" {} # single regional provider — VPC, TGW, and VPN share it

# Networking foundation
module "vpc" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
  name   = "core"
  #... cidr, subnets, route tables...
}

# Transit Gateway hub (the VPN terminates here)
module "tgw" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-transit-gateway?ref=v1.0.0"
  name   = "core-tgw"
  #... amazon_side_asn, attachments...
}

# Tunnel log group for IKE + BGP diagnostics
module "vpn_logs" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-cloudwatch-log-group?ref=v1.0.0"
  name   = "/casey/vpn/core"
}

# This module — the hybrid Site-to-Site VPN, terminating on the TGW
module "vpn" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpn?ref=v1.0.0"

  transit_gateway_id           = module.tgw.id
  customer_gateway_ip_address  = "203.0.113.10"
  customer_gateway_bgp_asn     = 65010
  customer_gateway_device_name = "datacenter-firewall-01"

  preshared_key_storage = "SecretsManager" # PSKs out of plain state

  tunnel1 = {
    log_options = { cloudwatch_log_options = { log_enabled = true, log_group_arn = module.vpn_logs.arn, bgp_log_enabled = true, bgp_log_group_arn = module.vpn_logs.arn } }
  }
  tunnel2 = {
    log_options = { cloudwatch_log_options = { log_enabled = true, log_group_arn = module.vpn_logs.arn } }
  }

  tags = { Environment = "prod", DataClass = "internal" }
}

# Hand the generated config to the on-prem team (SENSITIVE — do not log)
output "vpn_device_config" {
  value     = module.vpn.customer_gateway_configuration
  sensitive = true
}

⚠️ The on-prem appliance is configured out-of-band from customer_gateway_configuration (which contains the PSKs) — this module never reaches the customer device. On the TGW path, associate module.vpn.transit_gateway_attachment_id with the appropriate Transit Gateway route table in your tf-mod-aws-transit-gateway call.


📥 Inputs

Name Type Default Description
vpc_id string null VGW termination path — VGW is created and attached to this VPC. Set exactly one of this / transit_gateway_id.
transit_gateway_id string null TGW termination path — connection attaches directly. FORCE-NEW.
customer_gateway_ip_address string null Public IPv4 of the on-prem device (optional when cert-based).
customer_gateway_bgp_asn number 65000 16/32-bit BGP ASN of the CGW (1–2147483647). FORCE-NEW. Conflicts with the extended ASN.
customer_gateway_bgp_asn_extended number null Large 32-bit ASN (2147483648–4294967295). FORCE-NEW. Use instead of the above.
customer_gateway_certificate_arn string null ACM / Private CA cert for a cert-based CGW (else PSK auth).
customer_gateway_device_name string null Friendly on-prem device name.
type string "ipsec.1" VPN type (only ipsec.1). FORCE-NEW.
vpn_gateway_amazon_side_asn number null Amazon-side ASN for the VGW (private ranges; null = 64512). VGW path.
vpn_gateway_availability_zone string null AZ for the VGW (null = AWS chooses). VGW path.
static_routes_only bool false Static routing (no BGP). Requires routes. Default = dynamic BGP.
enable_acceleration bool false Global Accelerator. TGW-path only. FORCE-NEW.
outside_ip_address_type string "PublicIpv4" PublicIpv4 | PrivateIpv4 (DX).
transport_transit_gateway_attachment_id string null TGW↔DX attachment for a private VPN. Required when PrivateIpv4.
vpn_concentrator_id string null Existing VPN concentrator (rare).
local_ipv4_network_cidr / remote_ipv4_network_cidr string 0.0.0.0/0 IPsec SA CIDRs (CGW / AWS side).
local_ipv6_network_cidr / remote_ipv6_network_cidr string null IPv6 SA CIDRs (TGW + ipv6 only).
tunnel_inside_ip_version string "ipv4" ipv4 | ipv6. ipv6 is TGW-path only.
tunnel_bandwidth string null standard | large. TGW-path only.
preshared_key_storage string null Standard | SecretsManager. Prefer SecretsManager for PII.
tunnel1 / tunnel2 object {} → hardened Per-tunnel IKE/DH/Phase-1&2 options + optional CloudWatch logging. Hardened IKEv2 baseline by default.
tunnel1_preshared_key / tunnel2_preshared_key string (sensitive) null Caller-supplied PSK (null = AWS-generated).
routes set(string) [] Static on-prem destination CIDRs (static routing).
route_table_ids set(string) [] VPC route tables to propagate into. VGW path only.
tags map(string) {} Tags for connection, customer gateway, VGW (merge with default_tags).

ℹ️ see variables.tf for the full heredoc schemas of tunnel1 / tunnel2.


🧾 Outputs

Name Description
id / vpn_connection_id VPN connection id (vpn-...).
arn VPN connection ARN — the cross-resource reference type.
transit_gateway_attachment_id TGW VPN attachment id (TGW path; null on VGW path).
preshared_key_arn Secrets Manager secret ARN for PSKs (SecretsManager storage).
customer_gateway_id / customer_gateway_arn Customer gateway id / ARN.
vpn_gateway_id / vpn_gateway_arn VGW id / ARN (VGW path; null on TGW path).
tunnel1_address / tunnel2_address Tunnel outside (public) IPs.
tunnel1_bgp_asn / tunnel2_bgp_asn Amazon-side BGP ASN per tunnel.
tunnel1_cgw_inside_address / tunnel2_cgw_inside_address Inside CGW-side addresses — sensitive.
tunnel1_vgw_inside_address / tunnel2_vgw_inside_address Inside AWS-side addresses — sensitive.
tunnel1_preshared_key / tunnel2_preshared_key Tunnel PSKs — sensitive.
customer_gateway_configuration Native device-config XML (contains PSKs) — sensitive.
route_destination_cidrs Map of static route key → destination CIDR.
propagated_route_table_ids Route table ids receiving propagated routes (VGW path).
tags_all All tags incl. provider default_tags (resource tags win).

⚠️ Sensitive outputs. Tunnel inside addresses, both PSKs, and customer_gateway_configuration are marked sensitive = true — they never appear in plan output or logs. Prefer the config download or SecretsManager PSK storage over consuming the PSK outputs directly; if you do reference them, keep the consuming output sensitive too.


🧠 Architecture Notes

  • ID / ARN formats. Connection id vpn-0a1b2c3d4e5f6a7b8; ARN arn:aws:ec2:<region>:<account-id>:vpn-connection/<id> — the cross-resource reference type, so both id and arn are surfaced. Customer gateway cgw-... / arn:aws:ec2:...:customer-gateway/<id>; VGW vgw-... / arn:aws:ec2:...:vpn-gateway/<id>. The TGW VPN attachment is tgw-attach-....
  • Force-new / immutable fields. On aws_vpn_connection: customer_gateway_id, vpn_gateway_id, transit_gateway_id, type, and enable_acceleration are FORCE-NEW — changing the termination target or the customer gateway recreates the connection and rotates the tunnel PSKs and outside IPs. On aws_customer_gateway: bgp_asn / bgp_asn_extended, ip_address, type, and certificate_arn are FORCE-NEW. Most per-tunnel IKE/IPsec options apply in place via ModifyVpnTunnelOptions, but changing a tunnel's inside_cidr replaces tunnel state.
  • tagstags_alldefault_tags. var.tags flows to the connection, customer gateway, and VGW; tags_all is the provider-computed merge of resource tags over provider default_tags, with resource tags winning on key conflict. default_tags lives in the caller's provider block — never inside this module. aws_vpn_gateway_attachment, aws_vpn_connection_route, and aws_vpn_gateway_route_propagation are NOT taggable (AWS limitation) — they expose no tags.
  • Eventual consistency. A freshly created connection reports both tunnels DOWN until the on-prem device negotiates IKE — tunnel*_address/PSKs are available from the apply, but tunnel state lags until the appliance is configured from customer_gateway_configuration. The service-linked role and (for SecretsManager storage) the PSK secret are created asynchronously by the VPN service.
  • Destroy ordering. Terraform sequences static routes / route propagations → VPN connection → VGW detach (aws_vpn_gateway_attachment) → VGW / customer gateway via implicit + explicit (depends_on) dependencies. A VGW will not detach while a connection exists, and a customer gateway cannot be deleted while referenced by a connection — the dependency graph handles this, but a manually-created out-of-band route on a propagated table can stall the route-table delete.
  • Two termination paths, one validation. (vpc_id == null) != (transit_gateway_id == null) enforces exactly one. TGW-only features (enable_acceleration, tunnel_bandwidth, tunnel_inside_ip_version = "ipv6") are gated so the provider never receives an argument that doesn't apply to the chosen path.
  • No us-east-1 constraint. Site-to-Site VPN is regional — the connection, VGW/CGW, and any TGW must share the provider's Region. The us-east-1 global-resource rule applies only to CloudFront/WAF-CLOUDFRONT/ACM-for-CloudFront, not here.

🧱 Design Principles

Secure-by-default posture and every opt-out, explicitly:

Posture Default Opt-out
IKE version IKEv2 only (ike_versions = ["ikev2"]) add ikev1 to a tunnel's ike_versions (discouraged; documented exception)
Phase-1/2 DH groups strong groups 14–24 (weak group 2 excluded) narrow / widen via tunnel*.phase{1,2}_dh_group_numbers
Phase-1/2 encryption AES-256 + AES-256-GCM set tunnel*.phase{1,2}_encryption_algorithms (e.g. add AES128 — discouraged)
Phase-1/2 integrity SHA-2 (256/384/512; SHA-1 excluded) add SHA1 via tunnel*.phase{1,2}_integrity_algorithms (discouraged)
Pre-shared keys AWS-generated, surfaced only as sensitive supply tunnel*_preshared_key (kept sensitive)
PSK storage Standard (service-held) preshared_key_storage = "SecretsManager" (preferred for PII — keys out of plain state)
Tunnel inside addresses distinct per tunnel, surfaced only as sensitive pin tunnel*.inside_cidr (must be distinct /30 in 169.254/16)
Routing dynamic BGP preferred static_routes_only = true (requires explicit routes)
Tunnel logging off (available) enable via tunnel*.log_options.cloudwatch_log_options
HA two tunnels always not configurable — fixed by AWS

Other principles:

  • One composite, one keystone. The connection owns only what is meaningless without it (the customer gateway, an optional VGW + attachment, static routes, route propagation). The VPC, Transit Gateway, certificate, DX attachment, and log group are consumed by reference.
  • for_each, never count, for routes and route_table_ids — keyed by the CIDR / route-table-id string so adding or removing one never re-indexes the others.
  • Secrets are sensitive-only. PSKs, tunnel inside addresses, and the device-config XML are emitted exclusively as sensitive outputs — honoring the no-secrets-in-logs standard.
  • Primary outputs id + arn, plus the customer-gateway / VGW ids, tunnel endpoints, route maps, and tags_all.
  • No credentials, no region variable. Credentials and Region come from the caller's provider block; the module inherits the single aws provider.

🚀 Runbook

# Validate without backend or credentials
terraform init -backend=false
terraform validate
terraform fmt -check

plan / apply require valid AWS credentials (profile / SSO / OIDC) resolved through the standard provider chain and a Region. The termination target (VPC for the VGW path, or Transit Gateway for the TGW path) must already exist in the same Region.

⚠️ Always pin the module source with ?ref=v1.0.0 — never a branch.


🧪 Testing

  • terraform init -backend=false && terraform validate — schema + termination-path validation (vpc_id XOR transit_gateway_id), ASN conflict checks, tunnel enum/CIDR validation.
  • terraform fmt -check — canonical formatting.
  • terraform plan against a sandbox account to confirm the customer gateway, optional VGW + attachment, connection, routes, and propagations materialize and that the hardened tunnel options are accepted.
  • Assert module.<name>.id, arn, customer_gateway_id, vpn_gateway_id (VGW path) / transit_gateway_attachment_id (TGW path), and tags_all in your root-module test harness. Treat tunnel*_preshared_key, the inside addresses, and customer_gateway_configuration as sensitive in assertions.

💬 Example Output

module.vpn.aws_customer_gateway.this: Creation complete [id=cgw-0a1b2c3d4e5f6a7b8]
module.vpn.aws_vpn_gateway.this["this"]: Creation complete [id=vgw-0123456789abcdef0]
module.vpn.aws_vpn_gateway_attachment.this["this"]: Creation complete
module.vpn.aws_vpn_connection.this: Still creating... [1m20s elapsed]
module.vpn.aws_vpn_connection.this: Creation complete after 4m12s [id=vpn-0a1b2c3d4e5f6a7b8]

Outputs:
arn = "arn:aws:ec2:us-east-1:123456789012:vpn-connection/vpn-0a1b2c3d4e5f6a7b8"
customer_gateway_id = "cgw-0a1b2c3d4e5f6a7b8"
id = "vpn-0a1b2c3d4e5f6a7b8"
tunnel1_address = "203.0.113.40"
tunnel2_address = "203.0.113.41"
tunnel1_preshared_key = (sensitive value)
vpn_gateway_id = "vgw-0123456789abcdef0"
tags_all = { "DataClass" = "internal", "Environment" = "prod" }

🔍 Troubleshooting

Symptom Likely cause Fix
Set exactly one termination path: vpc_id (VGW) OR transit_gateway_id (TGW) Both or neither set Set exactly one of vpc_id / transit_gateway_id
enable_acceleration is only supported on the Transit Gateway path enable_acceleration = true with vpc_id Acceleration / tunnel_bandwidth / ipv6 tunnels are TGW-path only
transport_transit_gateway_attachment_id is required when... PrivateIpv4 Private VPN without the DX transport attachment Wire transport_transit_gateway_attachment_id from tf-mod-aws-dx
Both tunnels stay DOWN after apply On-prem device not yet configured Apply the customer_gateway_configuration to the appliance; check IKE phase-1 proposal overlap
IKE negotiation fails / no proposal chosen On-prem device cannot do the hardened baseline Relax tunnel* crypto only as far as needed (documented exception — see example 15)
Plan wants to replace the connection Changed a FORCE-NEW field (type, termination target, customer gateway, enable_acceleration) Expected — tunnel PSKs and outside IPs rotate; plan the cutover with the on-prem team
customer_gateway_bgp_asn conflict error Both 16/32-bit and extended ASN set Set exactly one; null the other
routes required but empty static_routes_only = true with no routes Add on-prem destination CIDRs, or use dynamic BGP (static_routes_only = false)
VGW won't detach / destroy hangs A connection still references the VGW, or a propagated route table is busy Let Terraform sequence the delete; remove out-of-band routes on propagated tables
iam:CreateServiceLinkedRole denied on first connection AWSServiceRoleForVPCS2SVPNInternal absent and not creatable Grant iam:CreateServiceLinkedRole, or pre-create the SLR once per account
Tag drift on every plan A tag also set by provider default_tags with a different value Let resource tags win, or remove the overlap from default_tags
Credential / NoCredentialProviders errors Provider chain not configured Set AWS_PROFILE / SSO / OIDC at the root; the module carries no credentials

🔗 Related Docs


🧡 "Infrastructure as Code should be standardized, consistent, and secure."

Packages

 
 
 

Contributors

Languages