Quick take — Reusable Terraform module for AWS Site-to-Site VPN: provision aws_vpn_connection with dynamic BGP routing, dual-tunnel options, IKEv2, and CloudWatch tunnel logging without hand-wiring every IPsec knob. 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 "vpn" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpn?ref=v1.0.0"
name = "..." # Base name used for tagging the VPN connection and custo…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Site-to-Site VPN establishes encrypted IPsec tunnels between your on-premises (or another cloud’s) network and AWS. A single aws_vpn_connection always provisions two tunnels terminating on AWS endpoints in different Availability Zones, giving you redundancy out of the box. You attach it to either a Virtual Private Gateway (aws_vpn_gateway, single-VPC) or a Transit Gateway (aws_ec2_transit_gateway, hub-and-spoke across many VPCs), and you choose between static routing or dynamic BGP routing.
The problem is that doing this correctly involves a sprawl of fiddly, security-sensitive arguments: per-tunnel pre-shared keys, inside CIDR allocation from the RFC 6996 169.254.0.0/16 range, IKE versions, Phase 1/Phase 2 DH groups and cipher suites, rekey margins, DPD (dead peer detection) timeouts, and tunnel-level CloudWatch logging. Hand-writing this for every spoke is error-prone and drifts between teams. This module wraps aws_vpn_connection (plus an optional aws_customer_gateway and the route plumbing) so a consumer supplies a handful of typed, validated inputs and gets two hardened tunnels with sane IKEv2 defaults, optional BGP, and observability wired in.
When to use it
- You need hybrid connectivity to AWS and cannot justify the cost/lead-time of Direct Connect, or you want VPN as the encrypted backup path over a Direct Connect.
- You are onboarding many branch sites or partner networks to a Transit Gateway hub and want each connection defined identically via a module call rather than copy-pasted HCL.
- You require BGP dynamic routing with ASN control and route propagation, instead of brittle static routes.
- You must meet a compliance baseline (IKEv2 only, strong DH groups, no AES-128, no weak SHA-1 integrity) consistently across every tunnel.
- Skip this module if you need a SaaS/agent-based mesh (use AWS Cloud WAN or a third-party SD-WAN) or if both ends are inside AWS (use VPC peering or TGW peering instead).
Module structure
terraform-module-aws-vpn/
├── versions.tf # provider + Terraform version pins
├── main.tf # customer gateway + vpn connection + routes
├── variables.tf # typed, validated inputs
└── outputs.tf # connection id, tunnel addresses, BGP info
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Tunnel options are only emitted when the consumer provides values,
# so AWS-assigned PSKs / inside CIDRs are left untouched when null.
enable_tunnel1_logging = var.tunnel_cloudwatch_log_group_arn != null
enable_tunnel2_logging = var.tunnel_cloudwatch_log_group_arn != null
}
# Optionally create the Customer Gateway representing the on-prem device.
# Set create_customer_gateway = false to reuse an existing one by id.
resource "aws_customer_gateway" "this" {
count = var.create_customer_gateway ? 1 : 0
bgp_asn = var.customer_gateway_bgp_asn
ip_address = var.customer_gateway_ip_address
type = "ipsec.1"
device_name = var.customer_gateway_device_name
tags = merge(var.tags, {
Name = "${var.name}-cgw"
})
}
locals {
customer_gateway_id = var.create_customer_gateway ? aws_customer_gateway.this[0].id : var.existing_customer_gateway_id
}
resource "aws_vpn_connection" "this" {
customer_gateway_id = local.customer_gateway_id
type = "ipsec.1"
# Attach to exactly one of: a Virtual Private Gateway or a Transit Gateway.
vpn_gateway_id = var.vpn_gateway_id
transit_gateway_id = var.transit_gateway_id
# static_routes_only = true -> static routing (you manage routes)
# static_routes_only = false -> dynamic BGP routing
static_routes_only = var.static_routes_only
# Optional outside IP version / local & remote selector CIDRs (IPv4 default).
tunnel_inside_ip_version = var.tunnel_inside_ip_version
local_ipv4_network_cidr = var.local_ipv4_network_cidr
remote_ipv4_network_cidr = var.remote_ipv4_network_cidr
enable_acceleration = var.enable_acceleration
outside_ip_address_type = var.outside_ip_address_type
# ---- Tunnel 1 ----
tunnel1_preshared_key = var.tunnel1_preshared_key
tunnel1_inside_cidr = var.tunnel1_inside_cidr
tunnel1_ike_versions = var.ike_versions
tunnel1_phase1_dh_group_numbers = var.phase1_dh_group_numbers
tunnel1_phase2_dh_group_numbers = var.phase2_dh_group_numbers
tunnel1_phase1_encryption_algorithms = var.phase1_encryption_algorithms
tunnel1_phase2_encryption_algorithms = var.phase2_encryption_algorithms
tunnel1_phase1_integrity_algorithms = var.phase1_integrity_algorithms
tunnel1_phase2_integrity_algorithms = var.phase2_integrity_algorithms
tunnel1_startup_action = var.tunnel_startup_action
tunnel1_dpd_timeout_action = var.dpd_timeout_action
dynamic "tunnel1_log_options" {
for_each = local.enable_tunnel1_logging ? [1] : []
content {
cloudwatch_log_options {
log_enabled = true
log_group_arn = var.tunnel_cloudwatch_log_group_arn
log_output_format = var.tunnel_log_output_format
}
}
}
# ---- Tunnel 2 ----
tunnel2_preshared_key = var.tunnel2_preshared_key
tunnel2_inside_cidr = var.tunnel2_inside_cidr
tunnel2_ike_versions = var.ike_versions
tunnel2_phase1_dh_group_numbers = var.phase1_dh_group_numbers
tunnel2_phase2_dh_group_numbers = var.phase2_dh_group_numbers
tunnel2_phase1_encryption_algorithms = var.phase1_encryption_algorithms
tunnel2_phase2_encryption_algorithms = var.phase2_encryption_algorithms
tunnel2_phase1_integrity_algorithms = var.phase1_integrity_algorithms
tunnel2_phase2_integrity_algorithms = var.phase2_integrity_algorithms
tunnel2_startup_action = var.tunnel_startup_action
tunnel2_dpd_timeout_action = var.dpd_timeout_action
dynamic "tunnel2_log_options" {
for_each = local.enable_tunnel2_logging ? [1] : []
content {
cloudwatch_log_options {
log_enabled = true
log_group_arn = var.tunnel_cloudwatch_log_group_arn
log_output_format = var.tunnel_log_output_format
}
}
}
tags = merge(var.tags, {
Name = var.name
})
}
# Static route(s) — only valid with a VGW and static_routes_only = true.
resource "aws_vpn_connection_route" "this" {
for_each = var.static_routes_only && var.vpn_gateway_id != null ? toset(var.static_route_cidrs) : toset([])
destination_cidr_block = each.value
vpn_connection_id = aws_vpn_connection.this.id
}
# Propagate BGP-learned routes into a route table (VGW + dynamic routing).
resource "aws_vpn_gateway_route_propagation" "this" {
for_each = !var.static_routes_only && var.vpn_gateway_id != null ? toset(var.propagation_route_table_ids) : toset([])
vpn_gateway_id = var.vpn_gateway_id
route_table_id = each.value
}
variables.tf
variable "name" {
description = "Base name used for tagging the VPN connection and customer gateway."
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 64
error_message = "name must be between 1 and 64 characters."
}
}
variable "tags" {
description = "Tags applied to all resources created by this module."
type = map(string)
default = {}
}
# ---- Customer Gateway ----
variable "create_customer_gateway" {
description = "Whether to create a Customer Gateway. If false, set existing_customer_gateway_id."
type = bool
default = true
}
variable "existing_customer_gateway_id" {
description = "ID of an existing Customer Gateway to reuse when create_customer_gateway = false."
type = string
default = null
}
variable "customer_gateway_ip_address" {
description = "Public IP address of the on-premises VPN device (the customer gateway)."
type = string
default = null
validation {
condition = var.customer_gateway_ip_address == null || can(regex("^(\\d{1,3}\\.){3}\\d{1,3}$", var.customer_gateway_ip_address))
error_message = "customer_gateway_ip_address must be a valid IPv4 address."
}
}
variable "customer_gateway_bgp_asn" {
description = "BGP ASN of the customer gateway. Use 65000 (or any private ASN) when routing is static."
type = number
default = 65000
validation {
condition = var.customer_gateway_bgp_asn >= 1 && var.customer_gateway_bgp_asn <= 4294967294
error_message = "customer_gateway_bgp_asn must be a valid ASN (1-4294967294)."
}
}
variable "customer_gateway_device_name" {
description = "Optional friendly name for the on-prem device (e.g. 'mum-edge-fw-01')."
type = string
default = null
}
# ---- Gateway attachment (choose exactly one) ----
variable "vpn_gateway_id" {
description = "Virtual Private Gateway ID to attach to. Mutually exclusive with transit_gateway_id."
type = string
default = null
}
variable "transit_gateway_id" {
description = "Transit Gateway ID to attach to. Mutually exclusive with vpn_gateway_id."
type = string
default = null
}
# ---- Routing ----
variable "static_routes_only" {
description = "true = static routing (manage routes yourself); false = dynamic BGP routing."
type = bool
default = false
}
variable "static_route_cidrs" {
description = "On-prem CIDRs to add as static routes (only used with a VGW + static_routes_only)."
type = list(string)
default = []
}
variable "propagation_route_table_ids" {
description = "Route table IDs to receive BGP route propagation (VGW + dynamic routing)."
type = list(string)
default = []
}
# ---- Connection-level options ----
variable "tunnel_inside_ip_version" {
description = "Inside IP version for the tunnels: ipv4 or ipv6."
type = string
default = "ipv4"
validation {
condition = contains(["ipv4", "ipv6"], var.tunnel_inside_ip_version)
error_message = "tunnel_inside_ip_version must be 'ipv4' or 'ipv6'."
}
}
variable "outside_ip_address_type" {
description = "Outside IP address type: PublicIpv4 or PrivateIpv4 (PrivateIpv4 requires a TGW + DX)."
type = string
default = "PublicIpv4"
validation {
condition = contains(["PublicIpv4", "PrivateIpv4"], var.outside_ip_address_type)
error_message = "outside_ip_address_type must be 'PublicIpv4' or 'PrivateIpv4'."
}
}
variable "enable_acceleration" {
description = "Enable AWS Global Accelerator for the tunnels (Transit Gateway connections only)."
type = bool
default = false
}
variable "local_ipv4_network_cidr" {
description = "IPv4 CIDR on the customer gateway (on-prem) side allowed over the tunnel."
type = string
default = "0.0.0.0/0"
}
variable "remote_ipv4_network_cidr" {
description = "IPv4 CIDR on the AWS side allowed over the tunnel."
type = string
default = "0.0.0.0/0"
}
# ---- Per-tunnel secrets / inside CIDRs (null => AWS auto-assigns) ----
variable "tunnel1_preshared_key" {
description = "Pre-shared key for tunnel 1. Leave null to let AWS generate one (recommended)."
type = string
default = null
sensitive = true
}
variable "tunnel2_preshared_key" {
description = "Pre-shared key for tunnel 2. Leave null to let AWS generate one (recommended)."
type = string
default = null
sensitive = true
}
variable "tunnel1_inside_cidr" {
description = "Inside /30 CIDR for tunnel 1 from 169.254.0.0/16 (null => AWS-assigned)."
type = string
default = null
}
variable "tunnel2_inside_cidr" {
description = "Inside /30 CIDR for tunnel 2 from 169.254.0.0/16 (null => AWS-assigned)."
type = string
default = null
}
# ---- IPsec/IKE hardening (apply to both tunnels) ----
variable "ike_versions" {
description = "Permitted IKE versions. Default to IKEv2 only for security."
type = list(string)
default = ["ikev2"]
validation {
condition = alltrue([for v in var.ike_versions : contains(["ikev1", "ikev2"], v)])
error_message = "ike_versions entries must be 'ikev1' or 'ikev2'."
}
}
variable "phase1_dh_group_numbers" {
description = "Permitted Diffie-Hellman group numbers for Phase 1."
type = list(number)
default = [14, 15, 16, 19, 20, 21]
}
variable "phase2_dh_group_numbers" {
description = "Permitted Diffie-Hellman group numbers for Phase 2."
type = list(number)
default = [14, 15, 16, 19, 20, 21]
}
variable "phase1_encryption_algorithms" {
description = "Permitted Phase 1 encryption algorithms (AES-256 / GCM only by default)."
type = list(string)
default = ["AES256", "AES256-GCM-16"]
validation {
condition = !contains(var.phase1_encryption_algorithms, "AES128") && !contains(var.phase1_encryption_algorithms, "AES128-GCM-16")
error_message = "AES128 ciphers are disallowed; use AES256 variants."
}
}
variable "phase2_encryption_algorithms" {
description = "Permitted Phase 2 encryption algorithms (AES-256 / GCM only by default)."
type = list(string)
default = ["AES256", "AES256-GCM-16"]
validation {
condition = !contains(var.phase2_encryption_algorithms, "AES128") && !contains(var.phase2_encryption_algorithms, "AES128-GCM-16")
error_message = "AES128 ciphers are disallowed; use AES256 variants."
}
}
variable "phase1_integrity_algorithms" {
description = "Permitted Phase 1 integrity algorithms (SHA-2 only by default)."
type = list(string)
default = ["SHA2-256", "SHA2-384", "SHA2-512"]
validation {
condition = !contains(var.phase1_integrity_algorithms, "SHA1")
error_message = "SHA1 integrity is disallowed; use SHA2-256 or stronger."
}
}
variable "phase2_integrity_algorithms" {
description = "Permitted Phase 2 integrity algorithms (SHA-2 only by default)."
type = list(string)
default = ["SHA2-256", "SHA2-384", "SHA2-512"]
validation {
condition = !contains(var.phase2_integrity_algorithms, "SHA1")
error_message = "SHA1 integrity is disallowed; use SHA2-256 or stronger."
}
}
variable "tunnel_startup_action" {
description = "Tunnel startup behaviour: 'add' (passive) or 'start' (AWS initiates IKE)."
type = string
default = "add"
validation {
condition = contains(["add", "start"], var.tunnel_startup_action)
error_message = "tunnel_startup_action must be 'add' or 'start'."
}
}
variable "dpd_timeout_action" {
description = "Action on DPD timeout: 'clear', 'none', or 'restart'."
type = string
default = "restart"
validation {
condition = contains(["clear", "none", "restart"], var.dpd_timeout_action)
error_message = "dpd_timeout_action must be 'clear', 'none', or 'restart'."
}
}
# ---- Tunnel logging ----
variable "tunnel_cloudwatch_log_group_arn" {
description = "CloudWatch Logs group ARN for tunnel activity logs. Null disables tunnel logging."
type = string
default = null
}
variable "tunnel_log_output_format" {
description = "Tunnel log output format: 'json' or 'text'."
type = string
default = "json"
validation {
condition = contains(["json", "text"], var.tunnel_log_output_format)
error_message = "tunnel_log_output_format must be 'json' or 'text'."
}
}
outputs.tf
output "vpn_connection_id" {
description = "ID of the Site-to-Site VPN connection."
value = aws_vpn_connection.this.id
}
output "vpn_connection_arn" {
description = "ARN of the Site-to-Site VPN connection."
value = aws_vpn_connection.this.arn
}
output "customer_gateway_id" {
description = "ID of the customer gateway in use (created or existing)."
value = local.customer_gateway_id
}
output "tunnel1_address" {
description = "Public IP address of the AWS endpoint for tunnel 1."
value = aws_vpn_connection.this.tunnel1_address
}
output "tunnel2_address" {
description = "Public IP address of the AWS endpoint for tunnel 2."
value = aws_vpn_connection.this.tunnel2_address
}
output "tunnel1_cgw_inside_address" {
description = "Inside (customer gateway side) IP address for tunnel 1, used for BGP peering."
value = aws_vpn_connection.this.tunnel1_cgw_inside_address
}
output "tunnel2_cgw_inside_address" {
description = "Inside (customer gateway side) IP address for tunnel 2, used for BGP peering."
value = aws_vpn_connection.this.tunnel2_cgw_inside_address
}
output "tunnel1_bgp_asn" {
description = "BGP ASN on the AWS side of tunnel 1 (relevant for dynamic routing)."
value = aws_vpn_connection.this.tunnel1_bgp_asn
}
output "tunnel1_preshared_key" {
description = "Pre-shared key for tunnel 1 (sensitive)."
value = aws_vpn_connection.this.tunnel1_preshared_key
sensitive = true
}
output "tunnel2_preshared_key" {
description = "Pre-shared key for tunnel 2 (sensitive)."
value = aws_vpn_connection.this.tunnel2_preshared_key
sensitive = true
}
How to use it
A common production pattern is a Transit Gateway hub with one VPN connection per branch site, using dynamic BGP routing and tunnel logging.
resource "aws_cloudwatch_log_group" "vpn" {
name = "/aws/vpn/mumbai-branch"
retention_in_days = 90
}
module "site_to_site_vpn" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpn?ref=v1.0.0"
name = "mumbai-branch"
# Create the customer gateway from the branch firewall's public IP + ASN.
create_customer_gateway = true
customer_gateway_ip_address = "203.0.113.40"
customer_gateway_bgp_asn = 65010
customer_gateway_device_name = "mum-edge-fw-01"
# Attach to the Transit Gateway hub with dynamic BGP routing.
transit_gateway_id = aws_ec2_transit_gateway.hub.id
static_routes_only = false
# Restrict the selectors instead of allowing 0.0.0.0/0 both ways.
local_ipv4_network_cidr = "10.50.0.0/16" # branch LAN
remote_ipv4_network_cidr = "10.0.0.0/8" # AWS aggregate
# Pin inside tunnel CIDRs so on-prem BGP config is deterministic.
tunnel1_inside_cidr = "169.254.10.0/30"
tunnel2_inside_cidr = "169.254.10.4/30"
# Tunnel activity logging to CloudWatch.
tunnel_cloudwatch_log_group_arn = aws_cloudwatch_log_group.vpn.arn
tags = {
Environment = "prod"
Owner = "network-team"
Site = "mumbai-branch"
}
}
# Downstream reference: alarm on tunnel-down using the connection id output.
resource "aws_cloudwatch_metric_alarm" "tunnel_down" {
alarm_name = "vpn-mumbai-branch-tunnel-down"
namespace = "AWS/VPN"
metric_name = "TunnelState"
statistic = "Maximum"
comparison_operator = "LessThanThreshold"
threshold = 1
period = 60
evaluation_periods = 3
treat_missing_data = "breaching"
dimensions = {
VpnId = module.site_to_site_vpn.vpn_connection_id
}
}
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/vpn/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpn?ref=v1.0.0"
}
inputs = {
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/vpn && 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 | Base name used for tagging the VPN connection and customer gateway. |
| tags | map(string) | {} | No | Tags applied to all resources created by this module. |
| create_customer_gateway | bool | true | No | Whether to create a Customer Gateway; if false, set existing_customer_gateway_id. |
| existing_customer_gateway_id | string | null | No | ID of an existing Customer Gateway to reuse. |
| customer_gateway_ip_address | string | null | Conditional | Public IP of the on-prem VPN device (required when creating a CGW). |
| customer_gateway_bgp_asn | number | 65000 | No | BGP ASN of the customer gateway. |
| customer_gateway_device_name | string | null | No | Friendly name for the on-prem device. |
| vpn_gateway_id | string | null | Conditional | Virtual Private Gateway ID (mutually exclusive with transit_gateway_id). |
| transit_gateway_id | string | null | Conditional | Transit Gateway ID (mutually exclusive with vpn_gateway_id). |
| static_routes_only | bool | false | No | true = static routing; false = dynamic BGP routing. |
| static_route_cidrs | list(string) | [] | No | On-prem CIDRs added as static routes (VGW + static routing). |
| propagation_route_table_ids | list(string) | [] | No | Route table IDs to receive BGP route propagation (VGW + dynamic). |
| tunnel_inside_ip_version | string | “ipv4” | No | Inside IP version: ipv4 or ipv6. |
| outside_ip_address_type | string | “PublicIpv4” | No | Outside IP type: PublicIpv4 or PrivateIpv4. |
| enable_acceleration | bool | false | No | Enable Global Accelerator (Transit Gateway connections only). |
| local_ipv4_network_cidr | string | “0.0.0.0/0” | No | IPv4 CIDR on the on-prem side allowed over the tunnel. |
| remote_ipv4_network_cidr | string | “0.0.0.0/0” | No | IPv4 CIDR on the AWS side allowed over the tunnel. |
| tunnel1_preshared_key | string (sensitive) | null | No | PSK for tunnel 1; null lets AWS generate one. |
| tunnel2_preshared_key | string (sensitive) | null | No | PSK for tunnel 2; null lets AWS generate one. |
| tunnel1_inside_cidr | string | null | No | Inside /30 CIDR for tunnel 1 from 169.254.0.0/16. |
| tunnel2_inside_cidr | string | null | No | Inside /30 CIDR for tunnel 2 from 169.254.0.0/16. |
| ike_versions | list(string) | [“ikev2”] | No | Permitted IKE versions. |
| phase1_dh_group_numbers | list(number) | [14,15,16,19,20,21] | No | Permitted Phase 1 DH group numbers. |
| phase2_dh_group_numbers | list(number) | [14,15,16,19,20,21] | No | Permitted Phase 2 DH group numbers. |
| phase1_encryption_algorithms | list(string) | [“AES256”,“AES256-GCM-16”] | No | Permitted Phase 1 encryption algorithms. |
| phase2_encryption_algorithms | list(string) | [“AES256”,“AES256-GCM-16”] | No | Permitted Phase 2 encryption algorithms. |
| phase1_integrity_algorithms | list(string) | [“SHA2-256”,“SHA2-384”,“SHA2-512”] | No | Permitted Phase 1 integrity algorithms. |
| phase2_integrity_algorithms | list(string) | [“SHA2-256”,“SHA2-384”,“SHA2-512”] | No | Permitted Phase 2 integrity algorithms. |
| tunnel_startup_action | string | “add” | No | Tunnel startup behaviour: add (passive) or start (AWS initiates). |
| dpd_timeout_action | string | “restart” | No | Action on DPD timeout: clear, none, or restart. |
| tunnel_cloudwatch_log_group_arn | string | null | No | CloudWatch Logs group ARN for tunnel logs; null disables logging. |
| tunnel_log_output_format | string | “json” | No | Tunnel log output format: json or text. |
Outputs
| Name | Description |
|---|---|
| vpn_connection_id | ID of the Site-to-Site VPN connection. |
| vpn_connection_arn | ARN of the Site-to-Site VPN connection. |
| customer_gateway_id | ID of the customer gateway in use (created or existing). |
| tunnel1_address | Public IP address of the AWS endpoint for tunnel 1. |
| tunnel2_address | Public IP address of the AWS endpoint for tunnel 2. |
| tunnel1_cgw_inside_address | Inside (CGW side) IP for tunnel 1, used for BGP peering. |
| tunnel2_cgw_inside_address | Inside (CGW side) IP for tunnel 2, used for BGP peering. |
| tunnel1_bgp_asn | BGP ASN on the AWS side of tunnel 1. |
| tunnel1_preshared_key | Pre-shared key for tunnel 1 (sensitive). |
| tunnel2_preshared_key | Pre-shared key for tunnel 2 (sensitive). |
Enterprise scenario
A retail company runs 120 stores, each with a branch firewall, and consolidates connectivity into a single Transit Gateway hub in ap-south-1. Their networking team loops this module in a for_each over a map of store records, passing each firewall’s public IP and a unique private BGP ASN, with static_routes_only = false so new store subnets propagate automatically without Terraform changes. Tunnel logs stream to a per-store CloudWatch Logs group for the SOC, and a CloudWatch alarm built from vpn_connection_id pages the on-call engineer if either tunnel on a store stays down for three minutes — turning what used to be 120 hand-built console connections into one reviewed, version-pinned module call.
Best practices
- Let AWS generate the pre-shared keys (leave
tunnel1_preshared_key/tunnel2_preshared_keynull) and read them back through the sensitive outputs into a secrets manager, rather than hard-coding PSKs in HCL where they land in plaintext state and Git history. - Pin the inside
/30CIDRs (169.254.x.y/30) and BGP ASNs per site so on-prem device configuration is deterministic and reproducible — auto-assigned inside CIDRs make standardizing branch firewall templates painful. - Use both tunnels, always. A single
aws_vpn_connectionis two tunnels across two AZs; configure your on-prem device to bring up both with equal BGP weight (or as active/standby via AS-path prepend) so an AWS-side tunnel maintenance event never drops the site. - Harden the crypto explicitly — keep the defaults of IKEv2-only, AES-256/GCM, SHA-2 integrity, and DH groups 14+; the module’s validations block AES-128 and SHA-1, which keeps every tunnel above common compliance baselines (PCI-DSS, CIS).
- Set
dpd_timeout_action = "restart"so dead tunnels re-establish automatically, and enable tunnel CloudWatch logging from day one — IPsec/IKE negotiation failures are nearly impossible to debug without it. - Cost-wise, remember you pay an hourly charge per VPN connection plus data-processing/egress; prefer one TGW-attached connection per site over multiple redundant connections, and only enable
enable_acceleration(Global Accelerator) where latency genuinely justifies the added cost.