IaC Azure

Terraform Module: Azure VPN Gateway Connection — Reusable site-to-site & VNet-to-VNet tunnels with IPsec/IKE policy

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

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 configlive/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 configlive/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

TerraformAzureVPN Gateway ConnectionModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading