Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure VPN Gateway — route-based gateways, active-active redundancy, BGP, local network gateways and IPsec connections, with validated inputs and sensible defaults. 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_gateway" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-vpn-gateway?ref=v1.0.0"
name = "..." # Gateway name; also prefixes public IPs, local network g…
resource_group_name = "..." # Resource group for the gateway and child resources.
location = "..." # Azure region (e.g. `centralindia`).
gateway_subnet_id = "..." # Resource ID of the subnet literally named `GatewaySubne…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure VPN Gateway is a managed virtual network gateway that terminates encrypted IPsec/IKE tunnels between an Azure VNet and on-premises networks (site-to-site), other Azure regions (VNet-to-VNet), or individual clients (point-to-site). It runs as a pair of instances inside a dedicated GatewaySubnet, attaches one or more public IPs, and — on the right SKUs — speaks BGP to exchange routes dynamically with your edge devices.
The raw building blocks are deceptively fiddly. A working site-to-site setup needs an azurerm_virtual_network_gateway wired to the correct subnet and public IP configuration, an azurerm_local_network_gateway describing the remote side, and an azurerm_virtual_network_gateway_connection holding the shared key and IPsec policy. Active-active gateways need exactly two ip_configuration blocks with two public IPs; BGP needs ASNs and APIPA peering addresses that must not collide with reserved ranges. Provisioning takes 30-45 minutes, so a typo costs you a coffee break and a terraform destroy.
This module wraps all of that behind a small, validated input surface: you pass a SKU, the gateway subnet ID, and a map of remote sites, and it produces the gateway, its public IPs, the local network gateways, and the connections — with active-active and BGP toggled by a single flag each.
When to use it
- You are standing up hybrid connectivity from Azure to a data centre or branch office over site-to-site IPsec and want it codified, not click-opsed.
- You run a hub-and-spoke topology where the VPN Gateway lives in the hub VNet and you need it reproduced across dev/test/prod or multiple regions.
- You need active-active redundancy with two tunnels to a pair of on-premises devices, and you do not want to hand-author the dual
ip_configurationand dual public IP plumbing every time. - You want BGP for dynamic routing and automatic failover instead of brittle static route tables on both ends.
- Skip this module if ExpressRoute already gives you private connectivity and you only need a VPN as a transit failover handled elsewhere, or if a fully-managed Virtual WAN hub (
azurerm_vpn_gateway) is your target — that is a different resource with a different operational model.
Module structure
terraform-module-azure-vpn-gateway/
├── 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 {
# Active-active gateways require two ip_configuration blocks and two public IPs.
public_ip_count = var.active_active ? 2 : 1
public_ip_names = [
for i in range(local.public_ip_count) : "${var.name}-pip-${i + 1}"
]
}
# One public IP per gateway instance. Standard SKU + Static is mandatory for
# the generation 2 / AZ-aware VPN gateway SKUs on azurerm ~> 4.0.
resource "azurerm_public_ip" "this" {
count = local.public_ip_count
name = local.public_ip_names[count.index]
resource_group_name = var.resource_group_name
location = var.location
allocation_method = "Static"
sku = "Standard"
zones = var.zones
tags = var.tags
}
resource "azurerm_virtual_network_gateway" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
type = "Vpn"
vpn_type = "RouteBased"
sku = var.sku
generation = var.generation
active_active = var.active_active
enable_bgp = var.enable_bgp
# Always primary; second block only emitted for active-active.
ip_configuration {
name = "vnetGatewayConfig"
public_ip_address_id = azurerm_public_ip.this[0].id
private_ip_address_allocation = "Dynamic"
subnet_id = var.gateway_subnet_id
}
dynamic "ip_configuration" {
for_each = var.active_active ? [1] : []
content {
name = "vnetGatewayConfig2"
public_ip_address_id = azurerm_public_ip.this[1].id
private_ip_address_allocation = "Dynamic"
subnet_id = var.gateway_subnet_id
}
}
dynamic "bgp_settings" {
for_each = var.enable_bgp ? [1] : []
content {
asn = var.bgp_asn
dynamic "peering_addresses" {
for_each = { for idx, cfg in local.public_ip_names : idx => cfg }
content {
ip_configuration_name = peering_addresses.key == 0 ? "vnetGatewayConfig" : "vnetGatewayConfig2"
apipa_addresses = try(var.bgp_apipa_addresses[peering_addresses.key], null)
}
}
}
}
tags = var.tags
}
# Remote side definitions — one per on-premises / peer site.
resource "azurerm_local_network_gateway" "this" {
for_each = var.connections
name = "${var.name}-lng-${each.key}"
resource_group_name = var.resource_group_name
location = var.location
gateway_address = each.value.gateway_address
address_space = each.value.address_space
dynamic "bgp_settings" {
for_each = each.value.bgp_peering_address != null ? [1] : []
content {
asn = each.value.bgp_asn
bgp_peering_address = each.value.bgp_peering_address
}
}
tags = var.tags
}
# IPsec site-to-site connections binding the gateway to each remote site.
resource "azurerm_virtual_network_gateway_connection" "this" {
for_each = var.connections
name = "${var.name}-conn-${each.key}"
resource_group_name = var.resource_group_name
location = var.location
type = "IPsec"
virtual_network_gateway_id = azurerm_virtual_network_gateway.this.id
local_network_gateway_id = azurerm_local_network_gateway.this[each.key].id
shared_key = each.value.shared_key
connection_protocol = "IKEv2"
enable_bgp = each.value.bgp_peering_address != null && var.enable_bgp
dpd_timeout_seconds = 45
use_policy_based_traffic_selectors = false
# Optional explicit IPsec/IKE policy; omitted falls back to Azure defaults.
dynamic "ipsec_policy" {
for_each = var.ipsec_policy != null ? [var.ipsec_policy] : []
content {
dh_group = ipsec_policy.value.dh_group
ike_encryption = ipsec_policy.value.ike_encryption
ike_integrity = ipsec_policy.value.ike_integrity
ipsec_encryption = ipsec_policy.value.ipsec_encryption
ipsec_integrity = ipsec_policy.value.ipsec_integrity
pfs_group = ipsec_policy.value.pfs_group
sa_lifetime = ipsec_policy.value.sa_lifetime
}
}
tags = var.tags
}
variables.tf
variable "name" {
type = string
description = "Name of the VPN gateway. Used as a prefix for public IPs, local network gateways and connections."
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-_]{1,78}[a-zA-Z0-9_]$", var.name))
error_message = "Name must be 3-80 chars, start alphanumeric, and contain only letters, numbers, hyphens or underscores."
}
}
variable "resource_group_name" {
type = string
description = "Resource group that will contain the gateway and its child resources."
}
variable "location" {
type = string
description = "Azure region for the gateway (e.g. centralindia, eastus)."
}
variable "gateway_subnet_id" {
type = string
description = "Resource ID of the dedicated 'GatewaySubnet'. Must be a /27 or larger for production SKUs and BGP."
validation {
condition = can(regex("/subnets/GatewaySubnet$", var.gateway_subnet_id))
error_message = "gateway_subnet_id must reference a subnet literally named 'GatewaySubnet' (Azure requirement)."
}
}
variable "sku" {
type = string
default = "VpnGw1AZ"
description = "Gateway SKU. Use *AZ SKUs for zone redundancy. Basic does not support BGP or active-active."
validation {
condition = contains([
"Basic",
"VpnGw1", "VpnGw2", "VpnGw3", "VpnGw4", "VpnGw5",
"VpnGw1AZ", "VpnGw2AZ", "VpnGw3AZ", "VpnGw4AZ", "VpnGw5AZ"
], var.sku)
error_message = "sku must be a supported VPN gateway SKU (Basic or VpnGw1-5, optionally AZ-suffixed)."
}
}
variable "generation" {
type = string
default = "Generation2"
description = "Gateway generation. Generation2 is required for the higher-throughput VpnGw2-5 SKUs; Basic only supports Generation1."
validation {
condition = contains(["Generation1", "Generation2"], var.generation)
error_message = "generation must be either 'Generation1' or 'Generation2'."
}
}
variable "zones" {
type = list(string)
default = ["1", "2", "3"]
description = "Availability zones for the public IPs. Only honoured with *AZ SKUs; ignored otherwise."
}
variable "active_active" {
type = bool
default = false
description = "Enable active-active mode. Provisions two public IPs and two ip_configuration blocks. Not supported on Basic SKU."
}
variable "enable_bgp" {
type = bool
default = false
description = "Enable BGP on the gateway for dynamic routing. Not supported on Basic SKU."
}
variable "bgp_asn" {
type = number
default = 65515
description = "BGP Autonomous System Number for the Azure gateway. Avoid reserved ASNs (e.g. 65515-65520 are Azure-reserved in some contexts; 65515 is the documented Azure default)."
}
variable "bgp_apipa_addresses" {
type = map(list(string))
default = {}
description = "Optional custom APIPA (169.254.21.x-169.254.22.x) BGP peering addresses keyed by ip_configuration index (0 and 1)."
}
variable "ipsec_policy" {
type = object({
dh_group = string
ike_encryption = string
ike_integrity = string
ipsec_encryption = string
ipsec_integrity = string
pfs_group = string
sa_lifetime = number
})
default = null
description = "Optional custom IPsec/IKE policy applied to every connection. Leave null to use Azure defaults."
}
variable "connections" {
type = map(object({
gateway_address = string
address_space = list(string)
shared_key = string
bgp_asn = optional(number)
bgp_peering_address = optional(string)
}))
default = {}
description = "Map of remote sites. Key becomes the connection/LNG suffix. address_space is ignored when BGP peering is used."
validation {
condition = alltrue([for c in values(var.connections) : length(c.shared_key) >= 8])
error_message = "Every connection shared_key must be at least 8 characters."
}
}
variable "tags" {
type = map(string)
default = {}
description = "Tags applied to all resources created by the module."
}
outputs.tf
output "id" {
description = "Resource ID of the VPN gateway."
value = azurerm_virtual_network_gateway.this.id
}
output "name" {
description = "Name of the VPN gateway."
value = azurerm_virtual_network_gateway.this.name
}
output "public_ip_addresses" {
description = "Public IP addresses assigned to the gateway (1 for active-passive, 2 for active-active)."
value = azurerm_public_ip.this[*].ip_address
}
output "public_ip_ids" {
description = "Resource IDs of the gateway public IPs."
value = azurerm_public_ip.this[*].id
}
output "bgp_peering_addresses" {
description = "Default BGP peering addresses (Azure-side) per ip_configuration, when BGP is enabled."
value = var.enable_bgp ? azurerm_virtual_network_gateway.this.bgp_settings[0].peering_addresses : []
}
output "connection_ids" {
description = "Map of connection name => resource ID for each site-to-site connection."
value = { for k, c in azurerm_virtual_network_gateway_connection.this : k => c.id }
}
output "local_network_gateway_ids" {
description = "Map of site key => local network gateway resource ID."
value = { for k, l in azurerm_local_network_gateway.this : k => l.id }
}
How to use it
module "vpn_gateway" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-vpn-gateway?ref=v1.0.0"
name = "kv-hub-vpngw-prod"
resource_group_name = azurerm_resource_group.hub.name
location = "centralindia"
gateway_subnet_id = azurerm_subnet.gateway.id # subnet MUST be named "GatewaySubnet"
sku = "VpnGw2AZ"
generation = "Generation2"
active_active = true
enable_bgp = true
bgp_asn = 65515
connections = {
mumbai-dc = {
gateway_address = "203.0.113.10"
address_space = ["10.50.0.0/16"]
shared_key = data.azurerm_key_vault_secret.psk_mumbai.value
bgp_asn = 64512
bgp_peering_address = "10.50.0.254"
}
pune-branch = {
gateway_address = "198.51.100.20"
address_space = ["10.60.0.0/16"]
shared_key = data.azurerm_key_vault_secret.psk_pune.value
}
}
tags = {
environment = "prod"
workload = "hybrid-connectivity"
managed_by = "terraform"
}
}
# Downstream: surface the gateway's public IPs so the network team can
# whitelist them on the on-premises firewalls, and pin a route via its ID.
output "vpn_gateway_public_ips" {
description = "Add these to the on-prem firewall allow-list for IPsec."
value = module.vpn_gateway.public_ip_addresses
}
resource "azurerm_monitor_diagnostic_setting" "vpngw" {
name = "vpngw-diag"
target_resource_id = module.vpn_gateway.id
log_analytics_workspace_id = azurerm_log_analytics_workspace.hub.id
enabled_log {
category = "GatewayDiagnosticLog"
}
enabled_log {
category = "TunnelDiagnosticLog"
}
enabled_log {
category = "RouteDiagnosticLog"
}
metric {
category = "AllMetrics"
}
}
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_gateway/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-gateway?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
gateway_subnet_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/vpn_gateway && 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 | Gateway name; also prefixes public IPs, local network gateways and connections. |
resource_group_name |
string |
— | yes | Resource group for the gateway and child resources. |
location |
string |
— | yes | Azure region (e.g. centralindia). |
gateway_subnet_id |
string |
— | yes | Resource ID of the subnet literally named GatewaySubnet (/27 or larger). |
sku |
string |
"VpnGw1AZ" |
no | Gateway SKU. *AZ for zone redundancy; Basic excludes BGP/active-active. |
generation |
string |
"Generation2" |
no | Generation1 or Generation2. |
zones |
list(string) |
["1","2","3"] |
no | Availability zones for public IPs; honoured only on *AZ SKUs. |
active_active |
bool |
false |
no | Provision two public IPs / ip_configuration blocks for redundancy. |
enable_bgp |
bool |
false |
no | Enable BGP dynamic routing on the gateway. |
bgp_asn |
number |
65515 |
no | Azure-side BGP ASN. |
bgp_apipa_addresses |
map(list(string)) |
{} |
no | Custom APIPA BGP peering addresses keyed by ip_configuration index. |
ipsec_policy |
object(...) |
null |
no | Custom IPsec/IKE policy applied to every connection. |
connections |
map(object(...)) |
{} |
no | Remote sites: gateway_address, address_space, shared_key, optional bgp_asn/bgp_peering_address. |
tags |
map(string) |
{} |
no | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the VPN gateway. |
name |
Name of the VPN gateway. |
public_ip_addresses |
Public IP addresses assigned to the gateway (1 or 2). |
public_ip_ids |
Resource IDs of the gateway public IPs. |
bgp_peering_addresses |
Azure-side BGP peering addresses per ip_configuration (when BGP is enabled). |
connection_ids |
Map of connection name to resource ID. |
local_network_gateway_ids |
Map of site key to local network gateway resource ID. |
Enterprise scenario
A retail group running a hub-and-spoke landing zone in centralindia uses this module to terminate IPsec tunnels from its Mumbai primary data centre and a Pune DR site into the hub VNet. The gateway runs VpnGw2AZ in active-active mode across all three availability zones with BGP enabled, so when one tunnel or zone drops, traffic re-converges over the surviving path automatically without a static route change. Pre-shared keys are pulled from Key Vault at plan time rather than committed to state in plaintext, and the module’s public_ip_addresses output feeds the firewall team’s allow-list pipeline.
Best practices
- Pick an
*AZSKU and pass all three zones for production. Zone-redundant gateways survive a single datacentre failure within the region; non-AZ SKUs do not, and the price delta is small relative to a connectivity outage. - Source pre-shared keys from Key Vault, never literals. Even via Terraform,
shared_keylands in state — keep state in an encrypted backend with RBAC, and rotate keys on a schedule usingterraform applyrather than the portal so drift stays visible. - Prefer BGP over static routes once you have more than a couple of prefixes. It gives automatic failover with active-active and avoids the toil of editing
address_spaceon both ends every time on-prem subnets change. Keep APIPA peering addresses inside169.254.21.0/24-169.254.22.0/24and clear of reserved values. - Size the GatewaySubnet at /27 or larger — Azure reserves addresses and a cramped /29 will block future co-existence (e.g. adding ExpressRoute) or extra instances; the subnet must be named exactly
GatewaySubnetor provisioning fails. - Right-size the SKU to measured throughput, not the top tier.
VpnGw1AZhandles ~650 Mbps aggregate; jumping toVpnGw5AZmultiplies cost several-fold, so scale up only when tunnel metrics justify it. - Enable diagnostic settings (TunnelDiagnosticLog, RouteDiagnosticLog, GatewayDiagnosticLog) to Log Analytics from day one. Tunnel flaps and BGP route changes are nearly impossible to debug after the fact without these logs, and they are cheap to retain.