Quick take — A production azurerm ~> 4.0 module for azurerm_virtual_network_gateway_connection: site-to-site, VNet-to-VNet and ExpressRoute tunnels with custom IPsec/IKE policy, BGP and shared-key inputs. 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 "azurerm" {
features {}
}
module "vpn_connection" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-vpn-connection?ref=v1.0.0"
name = "..." # Name of the gateway connection (1-80 chars, validated).
resource_group_name = "..." # Resource group holding the connection.
location = "..." # Region; must match the virtual network gateway.
virtual_network_gateway_id = "..." # Resource ID of this side's virtual network gateway.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure VPN Gateway Connection is the link object that actually carries traffic between a azurerm_virtual_network_gateway (a VPN or ExpressRoute gateway sitting in a GatewaySubnet) and a peer — an on-premises device modelled as a azurerm_local_network_gateway, another virtual network gateway, or an ExpressRoute circuit. The gateway by itself routes nothing; the connection is where you set the type (IPsec, Vnet2Vnet, ExpressRoute), the pre-shared key, the optional custom IPsec/IKE proposal, BGP enablement, DPD timeout and the connection protocol (IKEv1 vs IKEv2).
In raw HCL this resource is deceptively fiddly: the valid argument set changes with type, the custom_bgp_addresses block has ordering rules, the ipsec_policy block must specify a complete and Azure-supported combination of SA lifetimes and Diffie-Hellman groups, and the shared key has to stay out of state diffs and out of source control. Wrapping it in a reusable module lets you encode those rules once — validation on type and connection_protocol, a sane default IPsec policy that satisfies Azure’s allowed enums, and a key sourced from Key Vault — so every spoke, every region and every partner tunnel is built identically instead of being hand-tuned in the portal.
When to use it
- You are building hub-and-spoke or vWAN-adjacent topologies where the hub VNet terminates one or more site-to-site (S2S) IPsec tunnels to branch offices, factories or partner data centres.
- You need VNet-to-VNet (Vnet2Vnet) encryption between two Azure regions or two subscriptions where global VNet peering is unsuitable (e.g. you specifically want IPsec-encrypted transit or BGP route exchange across the link).
- You are connecting a VNet gateway to an ExpressRoute circuit and want the connection (and its optional authorization key / routing weight) managed as code.
- You must enforce a custom IPsec/IKE policy for compliance (for example AES-256 + SHA-256 + DHGroup14/GCMAES256) rather than accepting Azure’s default proposal set.
- You want BGP across the tunnel for dynamic route propagation instead of statically listing on-prem prefixes on the local network gateway.
If you only need a single throwaway tunnel for a lab, the module is overkill — but for anything that lives in production, repeats per branch, or has an auditor attached to it, codify it.
Module structure
terraform-module-azure-vpn-connection/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
# main.tf
locals {
# Azure only accepts the ipsec_policy block when a custom policy is supplied.
use_custom_ipsec = var.ipsec_policy != null
}
resource "azurerm_virtual_network_gateway_connection" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
type = var.type
virtual_network_gateway_id = var.virtual_network_gateway_id
# IPsec site-to-site: requires a local network gateway + shared key.
local_network_gateway_id = var.type == "IPsec" ? var.local_network_gateway_id : null
# Vnet2Vnet: requires the peer virtual network gateway.
peer_virtual_network_gateway_id = var.type == "Vnet2Vnet" ? var.peer_virtual_network_gateway_id : null
# ExpressRoute: requires the circuit; shared key/policy are not used.
express_route_circuit_id = var.type == "ExpressRoute" ? var.express_route_circuit_id : null
# Shared key applies to IPsec and Vnet2Vnet only.
shared_key = var.type == "ExpressRoute" ? null : var.shared_key
connection_protocol = var.type == "IPsec" ? var.connection_protocol : null
enable_bgp = var.type == "ExpressRoute" ? null : var.enable_bgp
use_policy_based_traffic_selectors = var.use_policy_based_traffic_selectors
dpd_timeout_seconds = var.type == "IPsec" ? var.dpd_timeout_seconds : null
routing_weight = var.routing_weight
express_route_gateway_bypass = var.type == "ExpressRoute" ? var.express_route_gateway_bypass : null
dynamic "ipsec_policy" {
for_each = local.use_custom_ipsec ? [var.ipsec_policy] : []
content {
sa_lifetime = ipsec_policy.value.sa_lifetime
sa_datasize = ipsec_policy.value.sa_datasize
ipsec_encryption = ipsec_policy.value.ipsec_encryption
ipsec_integrity = ipsec_policy.value.ipsec_integrity
ike_encryption = ipsec_policy.value.ike_encryption
ike_integrity = ipsec_policy.value.ike_integrity
dh_group = ipsec_policy.value.dh_group
pfs_group = ipsec_policy.value.pfs_group
}
}
# Override the BGP peering IP advertised on this connection (e.g. APIPA addresses
# for active-active gateways). Only meaningful when enable_bgp = true.
dynamic "custom_bgp_addresses" {
for_each = var.enable_bgp && var.custom_bgp_addresses != null ? [var.custom_bgp_addresses] : []
content {
primary = custom_bgp_addresses.value.primary
secondary = custom_bgp_addresses.value.secondary
}
}
tags = var.tags
}
# variables.tf
variable "name" {
type = string
description = "Name of the virtual network gateway connection."
validation {
condition = can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$", var.name))
error_message = "name must be 1-80 chars, start alphanumeric, and contain only letters, numbers, '.', '_' or '-'."
}
}
variable "resource_group_name" {
type = string
description = "Resource group that holds the connection (typically the hub/gateway RG)."
}
variable "location" {
type = string
description = "Azure region. Must match the region of the virtual network gateway."
}
variable "type" {
type = string
description = "Connection type: IPsec (site-to-site), Vnet2Vnet, or ExpressRoute."
default = "IPsec"
validation {
condition = contains(["IPsec", "Vnet2Vnet", "ExpressRoute"], var.type)
error_message = "type must be one of: IPsec, Vnet2Vnet, ExpressRoute."
}
}
variable "virtual_network_gateway_id" {
type = string
description = "Resource ID of the local (this side) azurerm_virtual_network_gateway."
}
variable "local_network_gateway_id" {
type = string
description = "Resource ID of the local network gateway (on-prem peer). Required when type = IPsec."
default = null
}
variable "peer_virtual_network_gateway_id" {
type = string
description = "Resource ID of the peer virtual network gateway. Required when type = Vnet2Vnet."
default = null
}
variable "express_route_circuit_id" {
type = string
description = "Resource ID of the ExpressRoute circuit. Required when type = ExpressRoute."
default = null
}
variable "shared_key" {
type = string
description = "Pre-shared key (PSK) for IPsec/Vnet2Vnet. Source from Key Vault; never hardcode."
default = null
sensitive = true
validation {
condition = var.shared_key == null || length(var.shared_key) >= 8
error_message = "shared_key must be at least 8 characters when supplied."
}
}
variable "connection_protocol" {
type = string
description = "IKE version for IPsec connections: IKEv1 or IKEv2."
default = "IKEv2"
validation {
condition = contains(["IKEv1", "IKEv2"], var.connection_protocol)
error_message = "connection_protocol must be IKEv1 or IKEv2."
}
}
variable "enable_bgp" {
type = bool
description = "Enable BGP route exchange across the connection (not applicable to ExpressRoute)."
default = false
}
variable "use_policy_based_traffic_selectors" {
type = bool
description = "Use policy-based (rather than route-based) traffic selectors. Needed for some legacy on-prem devices."
default = false
}
variable "dpd_timeout_seconds" {
type = number
description = "Dead Peer Detection timeout in seconds for IPsec connections."
default = 45
validation {
condition = var.dpd_timeout_seconds >= 9 && var.dpd_timeout_seconds <= 3600
error_message = "dpd_timeout_seconds must be between 9 and 3600."
}
}
variable "routing_weight" {
type = number
description = "Routing weight for the connection (higher wins when multiple paths exist). 0-32000."
default = 0
validation {
condition = var.routing_weight >= 0 && var.routing_weight <= 32000
error_message = "routing_weight must be between 0 and 32000."
}
}
variable "express_route_gateway_bypass" {
type = bool
description = "Bypass the ExpressRoute gateway for data forwarding (FastPath). Only for type = ExpressRoute."
default = false
}
variable "ipsec_policy" {
description = <<-EOT
Optional custom IPsec/IKE policy. When null, Azure's default proposal set is used.
All fields must be a valid Azure-supported combination.
EOT
type = object({
sa_lifetime = number
sa_datasize = number
ipsec_encryption = string
ipsec_integrity = string
ike_encryption = string
ike_integrity = string
dh_group = string
pfs_group = string
})
default = null
validation {
condition = var.ipsec_policy == null || (
contains(["AES128", "AES192", "AES256", "GCMAES128", "GCMAES192", "GCMAES256", "DES", "DES3", "None"], var.ipsec_policy.ipsec_encryption) &&
contains(["DHGroup1", "DHGroup2", "DHGroup14", "DHGroup24", "ECP256", "ECP384", "DHGroup2048"], var.ipsec_policy.dh_group)
)
error_message = "ipsec_policy.ipsec_encryption / dh_group must be Azure-supported values."
}
validation {
condition = var.ipsec_policy == null || (var.ipsec_policy.sa_lifetime >= 300 && var.ipsec_policy.sa_lifetime <= 172799)
error_message = "ipsec_policy.sa_lifetime must be between 300 and 172799 seconds."
}
}
variable "custom_bgp_addresses" {
description = "Override BGP peering IPs (APIPA) for active-active gateways. Requires enable_bgp = true."
type = object({
primary = string
secondary = optional(string)
})
default = null
}
variable "tags" {
type = map(string)
description = "Tags applied to the connection."
default = {}
}
# outputs.tf
output "id" {
description = "Resource ID of the virtual network gateway connection."
value = azurerm_virtual_network_gateway_connection.this.id
}
output "name" {
description = "Name of the connection."
value = azurerm_virtual_network_gateway_connection.this.name
}
output "type" {
description = "Connection type (IPsec, Vnet2Vnet or ExpressRoute)."
value = azurerm_virtual_network_gateway_connection.this.type
}
output "resource_guid" {
description = "Read-only GUID assigned to the connection by Azure."
value = azurerm_virtual_network_gateway_connection.this.resource_guid
}
output "enable_bgp" {
description = "Whether BGP is enabled on this connection."
value = azurerm_virtual_network_gateway_connection.this.enable_bgp
}
How to use it
A hub VPN gateway terminating a site-to-site tunnel to an on-prem branch, with a custom AES-256 IPsec policy and the PSK pulled from Key Vault:
data "azurerm_key_vault_secret" "branch_psk" {
name = "branch-mumbai-s2s-psk"
key_vault_id = var.kv_id
}
module "vpn_gateway_connection" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-vpn-connection?ref=v1.0.0"
name = "cn-hub-to-branch-mumbai"
resource_group_name = azurerm_resource_group.hub.name
location = azurerm_resource_group.hub.location
type = "IPsec"
virtual_network_gateway_id = azurerm_virtual_network_gateway.hub.id
local_network_gateway_id = azurerm_local_network_gateway.branch_mumbai.id
connection_protocol = "IKEv2"
shared_key = data.azurerm_key_vault_secret.branch_psk.value
enable_bgp = true
dpd_timeout_seconds = 30
ipsec_policy = {
sa_lifetime = 27000
sa_datasize = 102400000
ipsec_encryption = "GCMAES256"
ipsec_integrity = "GCMAES256"
ike_encryption = "AES256"
ike_integrity = "SHA256"
dh_group = "DHGroup14"
pfs_group = "PFS24"
}
tags = {
environment = "prod"
workload = "connectivity"
peer = "branch-mumbai"
}
}
# Downstream: alert when the connection's BGP status changes, keyed off the connection ID.
resource "azurerm_monitor_metric_alert" "tunnel_egress" {
name = "alert-tunnel-mumbai-egress"
resource_group_name = azurerm_resource_group.hub.name
scopes = [module.vpn_gateway_connection.id]
description = "Egress bytes on the Mumbai S2S tunnel dropped to zero."
criteria {
metric_namespace = "Microsoft.Network/connections"
metric_name = "BitsOutPerSecond"
aggregation = "Average"
operator = "LessThanOrEqual"
threshold = 0
}
}
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 = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/vpn_connection/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-vpn-connection?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
virtual_network_gateway_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/vpn_connection && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the gateway connection (1-80 chars, validated). |
resource_group_name |
string |
— | Yes | Resource group holding the connection. |
location |
string |
— | Yes | Region; must match the virtual network gateway. |
type |
string |
"IPsec" |
No | IPsec, Vnet2Vnet, or ExpressRoute. |
virtual_network_gateway_id |
string |
— | Yes | Resource ID of this side’s virtual network gateway. |
local_network_gateway_id |
string |
null |
Conditional | On-prem peer; required when type = IPsec. |
peer_virtual_network_gateway_id |
string |
null |
Conditional | Peer gateway; required when type = Vnet2Vnet. |
express_route_circuit_id |
string |
null |
Conditional | Circuit ID; required when type = ExpressRoute. |
shared_key |
string (sensitive) |
null |
Conditional | PSK for IPsec/Vnet2Vnet; min 8 chars. |
connection_protocol |
string |
"IKEv2" |
No | IKEv1 or IKEv2 (IPsec only). |
enable_bgp |
bool |
false |
No | Enable BGP route exchange (not ExpressRoute). |
use_policy_based_traffic_selectors |
bool |
false |
No | Policy-based selectors for legacy peers. |
dpd_timeout_seconds |
number |
45 |
No | Dead Peer Detection timeout, 9-3600 (IPsec only). |
routing_weight |
number |
0 |
No | Path preference weight, 0-32000. |
express_route_gateway_bypass |
bool |
false |
No | FastPath bypass for type = ExpressRoute. |
ipsec_policy |
object |
null |
No | Custom IPsec/IKE proposal; validated enums. |
custom_bgp_addresses |
object |
null |
No | APIPA BGP peering override; needs enable_bgp. |
tags |
map(string) |
{} |
No | Resource tags. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the virtual network gateway connection. |
name |
Name of the connection. |
type |
Connection type (IPsec, Vnet2Vnet, ExpressRoute). |
resource_guid |
Azure-assigned read-only GUID for the connection. |
enable_bgp |
Whether BGP is enabled on this connection. |
Enterprise scenario
A retail group runs a hub-and-spoke landing zone where the hub VNet hosts an active-active zone-redundant VPN gateway. Each of 60 stores has a local network gateway entry, and a single for_each over a store map instantiates this module 60 times to build 60 IPsec connections — every one with the same compliance-mandated GCMAES256 / DHGroup14 IPsec policy and BGP enabled so new store subnets propagate automatically without editing static prefixes. PSKs are stored per-store in Key Vault and referenced by name, so rotating a branch key is a Key Vault write plus a terraform apply rather than a portal click-through, and the id outputs feed a metric-alert module that pages NetOps the moment any tunnel’s egress drops to zero.
Best practices
- Never put the PSK in HCL or state in clear text. Source
shared_keyfromazurerm_key_vault_secretand keep the variablesensitive = true; rotate keys on a schedule and re-apply, since Azure does not expose key rotation as a first-class operation. - Pin a custom IPsec/IKE policy in regulated environments. Accepting Azure’s default proposal set means your encryption parameters can shift under you; an explicit
GCMAES256+SHA256+DHGroup14/PFS24policy is auditable and must exactly match the on-prem device or the tunnel silently fails Phase 2. - Prefer BGP over static prefixes for anything that grows. Enabling
enable_bgplets new subnets on either side advertise automatically; reserve static local-network-gateway address spaces for small, fixed peers, and usecustom_bgp_addresses(APIPA) when terminating on an active-active gateway. - Tune
dpd_timeout_secondsto the peer, not the default. Flaky branch links benefit from a shorter DPD (e.g. 30s) so dead tunnels are torn down and re-established quickly; over-aggressive values on a stable circuit cause needless renegotiation. - Right-size the gateway SKU, not the connection. Connection throughput is bounded by the
azurerm_virtual_network_gatewaySKU and aggregate tunnel count — a VpnGw2 caps lower than VpnGw5; cost and reliability are decided one level up, so size the gateway for total S2S/VNet2VNet bandwidth before scaling out connections. - Name connections by peer and direction (
cn-hub-to-branch-mumbai) and monitor eachidwithMicrosoft.Network/connectionsmetrics (BitsInPerSecond,TunnelAverageBandwidth,BitsOutPerSecond) so a down tunnel is alertable per-branch rather than buried in a gateway-wide aggregate.