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.
- 🔗 One hybrid link, fully wired. Creates
aws_vpn_connection(the keystone) plus everything that is meaningless without it: theaws_customer_gatewaydescribing the on-prem device, an optionalaws_vpn_gateway+aws_vpn_gateway_attachment, optional staticaws_vpn_connection_routeentries, and optionalaws_vpn_gateway_route_propagationinto 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
sensitiveoutputs — never plain. PSKs are AWS-generated unless you supply your own, andpreshared_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/16for 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 atf-mod-aws-cloudwatch-log-groupARN 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 deepobject(...)with baked-in hardened defaults andvalidation {}on every enum and closed set. - 🏷️ Tags everywhere taggable.
var.tagsmerges with providerdefault_tagsand flows to the connection, customer gateway, and VGW;tags_allis 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.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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
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
| 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.
| 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.
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.
AWSServiceRoleForVPCS2SVPNInternalis auto-created on the first Site-to-Site VPN connection in the account; the identity needsiam:CreateServiceLinkedRoleonly 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 generatedcustomer_gateway_configuration— no IAM is involved on that side.
- Service-linked role.
AWSServiceRoleForVPCS2SVPNInternalmust exist (auto-created on first connection; the identity may neediam:CreateServiceLinkedRole). - A termination target must exist — set exactly one:
- VGW path: a VPC to attach to. Wire
vpc_idfromtf-mod-aws-vpc. The module creates and attaches the VGW for you. - TGW path: an existing Transit Gateway. Wire
transit_gateway_idfromtf-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 explicitroutesentries; dynamic (BGP) connections advertise routes automatically. The VGW path may propagate routes into VPC route tables viaroute_table_ids. - TGW-only features.
enable_acceleration(Global Accelerator),tunnel_bandwidth(large= 5 Gbps), andtunnel_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 requirestransport_transit_gateway_attachment_idfrom 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.
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
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 ofvpc_id(VGW path) ortransit_gateway_id(TGW path) — validation rejects both or neither.
| 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 |
| 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 |
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_id3 · 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 / rotation9 · 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 fromcustomer_gateway_configuration(which contains the PSKs) — this module never reaches the customer device. On the TGW path, associatemodule.vpn.transit_gateway_attachment_idwith the appropriate Transit Gateway route table in yourtf-mod-aws-transit-gatewaycall.
| 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.tffor the full heredoc schemas oftunnel1/tunnel2.
| 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, andcustomer_gateway_configurationare markedsensitive = true— they never appear in plan output or logs. Prefer the config download orSecretsManagerPSK storage over consuming the PSK outputs directly; if you do reference them, keep the consuming outputsensitivetoo.
- ID / ARN formats. Connection id
vpn-0a1b2c3d4e5f6a7b8; ARNarn:aws:ec2:<region>:<account-id>:vpn-connection/<id>— the cross-resource reference type, so bothidandarnare surfaced. Customer gatewaycgw-.../arn:aws:ec2:...:customer-gateway/<id>; VGWvgw-.../arn:aws:ec2:...:vpn-gateway/<id>. The TGW VPN attachment istgw-attach-.... - Force-new / immutable fields. On
aws_vpn_connection:customer_gateway_id,vpn_gateway_id,transit_gateway_id,type, andenable_accelerationare FORCE-NEW — changing the termination target or the customer gateway recreates the connection and rotates the tunnel PSKs and outside IPs. Onaws_customer_gateway:bgp_asn/bgp_asn_extended,ip_address,type, andcertificate_arnare FORCE-NEW. Most per-tunnel IKE/IPsec options apply in place viaModifyVpnTunnelOptions, but changing a tunnel'sinside_cidrreplaces tunnel state. tags↔tags_all↔default_tags.var.tagsflows to the connection, customer gateway, and VGW;tags_allis the provider-computed merge of resource tags over providerdefault_tags, with resource tags winning on key conflict.default_tagslives in the caller's provider block — never inside this module.aws_vpn_gateway_attachment,aws_vpn_connection_route, andaws_vpn_gateway_route_propagationare NOT taggable (AWS limitation) — they expose notags.- Eventual consistency. A freshly created connection reports both tunnels
DOWNuntil the on-prem device negotiates IKE —tunnel*_address/PSKs are available from the apply, but tunnel state lags until the appliance is configured fromcustomer_gateway_configuration. The service-linked role and (forSecretsManagerstorage) 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.
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, nevercount, forroutesandroute_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
sensitiveoutputs — honoring the no-secrets-in-logs standard. - Primary outputs
id+arn, plus the customer-gateway / VGW ids, tunnel endpoints, route maps, andtags_all. - No credentials, no region variable. Credentials and Region come from the caller's provider block; the module inherits the single
awsprovider.
# Validate without backend or credentials
terraform init -backend=false
terraform validate
terraform fmt -check
plan/applyrequire 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.
terraform init -backend=false && terraform validate— schema + termination-path validation (vpc_idXORtransit_gateway_id), ASN conflict checks, tunnel enum/CIDR validation.terraform fmt -check— canonical formatting.terraform planagainst 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), andtags_allin your root-module test harness. Treattunnel*_preshared_key, the inside addresses, andcustomer_gateway_configurationas sensitive in assertions.
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" }
| 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 |
- What is AWS Site-to-Site VPN? · Get started
- Tunnel options for your VPN connection · Customer gateway devices
- AWS Site-to-Site VPN quotas
- Terraform:
aws_vpn_connection·aws_customer_gateway·aws_vpn_gateway·aws_vpn_connection_route·aws_vpn_gateway_route_propagation - Sibling modules:
tf-mod-aws-vpc,tf-mod-aws-transit-gateway,tf-mod-aws-dx,tf-mod-aws-acm,tf-mod-aws-cloudwatch-log-group,tf-mod-aws-secrets-manager - Module internals:
SCOPE.md
🧡 "Infrastructure as Code should be standardized, consistent, and secure."