IaC Azure

Terraform Module: Azure VPN Gateway — production-ready hybrid connectivity in one wrapper

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

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 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_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

TerraformAzureVPN GatewayModuleIaC
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