Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for azurerm_local_network_gateway: represent on-prem VPN endpoints, BGP peering, and address spaces for Site-to-Site connections. 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 "local_network_gateway" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-local-network-gateway?ref=v1.0.0"
name = "..." # Name of the Local Network Gateway (validated 3-80 chars…
resource_group_name = "..." # Resource group that contains the LNG.
location = "..." # Azure region, usually matching the VPN 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 Local Network Gateway (LNG) is the Azure-side representation of your on-premises network in a Site-to-Site (S2S) IPsec/IKE VPN. Despite the word “gateway”, it is not a deployed appliance — it is a metadata object that tells the Azure VPN Gateway three things: the public IP (or FQDN) of your on-prem VPN device, the address ranges that live behind it, and (optionally) the BGP peering details so routes can be exchanged dynamically instead of statically listed. It is one half of every S2S tunnel; the azurerm_virtual_network_gateway_connection glues the Azure VPN Gateway to this LNG.
Wrapping azurerm_local_network_gateway in a reusable module pays off because the values it holds are exactly the ones that drift and multiply in real estates: every branch office, every co-lo, every partner extranet is a separate LNG, and each carries an IP and a list of CIDRs that the network team changes without warning. A module gives you one validated contract for “describe a remote site” — it normalises naming, enforces that you supply either a static address space or BGP (not a misconfigured mix), validates the gateway IP, and emits the id that the connection resource consumes. You stamp it out per site instead of hand-copying blocks that quietly fall out of sync with the firewall on the other end.
When to use it
- You are building Site-to-Site VPN connectivity from Azure to one or more on-premises / co-location sites and need an LNG per remote endpoint.
- You run a hub-and-spoke topology where a central VPN Gateway terminates tunnels to many branches, each modelled as its own LNG with distinct address spaces.
- You need BGP between Azure and on-prem (for active-active gateways, redundant tunnels, or to avoid maintaining static prefix lists) and want the peer ASN / BGP peering IP captured as code.
- You are migrating from ClickOps and want every remote site’s public IP and behind-the-gateway CIDRs in version control with PR review, instead of edited live in the portal.
- You front a third-party / partner network and want the LNG (and its allowed prefixes) auditable and diff-able.
Do not use this for the Azure VPN Gateway itself (azurerm_virtual_network_gateway), for ExpressRoute, or for VNet peering — the LNG only describes the remote side of an IPsec tunnel.
Module structure
terraform-module-azure-local-network-gateway/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_local_network_gateway (+ optional BGP)
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, name, gateway_address, address_space, bgp
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Exactly one of gateway_address (IPv4) or gateway_fqdn must be set.
uses_fqdn = var.gateway_fqdn != null
}
resource "azurerm_local_network_gateway" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
# Static endpoint identification: an IP OR an FQDN, never both.
gateway_address = local.uses_fqdn ? null : var.gateway_address
gateway_fqdn = local.uses_fqdn ? var.gateway_fqdn : null
# CIDR(s) reachable behind the on-prem device. Omit when using pure BGP,
# but most production tunnels still advertise at least one static prefix.
address_space = var.address_space
# Optional BGP peering with the on-prem router. When present, Azure can
# learn/advertise routes dynamically over the tunnel instead of relying
# solely on the static address_space list.
dynamic "bgp_settings" {
for_each = var.bgp_settings == null ? [] : [var.bgp_settings]
content {
asn = bgp_settings.value.asn
bgp_peering_address = bgp_settings.value.bgp_peering_address
peer_weight = bgp_settings.value.peer_weight
}
}
tags = var.tags
}
variables.tf
variable "name" {
type = string
description = "Name of the Local Network Gateway. Convention: lng-<site>-<region>-<env>."
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 and a valid Azure network resource name."
}
}
variable "resource_group_name" {
type = string
description = "Resource group that will contain the Local Network Gateway."
}
variable "location" {
type = string
description = "Azure region for the LNG (typically the same region as the VPN Gateway)."
}
variable "gateway_address" {
type = string
default = null
description = "Public IPv4 address of the on-premises VPN device. Mutually exclusive with gateway_fqdn."
validation {
condition = var.gateway_address == null || can(regex(
"^((25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])$",
var.gateway_address
))
error_message = "gateway_address must be a valid IPv4 address (e.g. 203.0.113.10)."
}
validation {
condition = var.gateway_address == null || (
var.gateway_address != "0.0.0.0" &&
!can(regex("^(10\\.|192\\.168\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.)", var.gateway_address))
)
error_message = "gateway_address must be a routable public IP, not RFC1918 / 0.0.0.0."
}
}
variable "gateway_fqdn" {
type = string
default = null
description = "FQDN of the on-premises VPN device (for dynamic-IP peers). Mutually exclusive with gateway_address."
}
variable "address_space" {
type = list(string)
default = []
description = "List of CIDR ranges reachable behind the on-prem gateway. May be empty only when bgp_settings is set."
validation {
condition = alltrue([
for c in var.address_space : can(cidrhost(c, 0))
])
error_message = "Every entry in address_space must be valid CIDR notation (e.g. 10.50.0.0/16)."
}
}
variable "bgp_settings" {
type = object({
asn = number
bgp_peering_address = string
peer_weight = optional(number, 0)
})
default = null
description = "Optional BGP peering with the on-prem router (ASN + peering IP inside the address space)."
validation {
condition = var.bgp_settings == null || (
var.bgp_settings.asn > 0 && var.bgp_settings.asn <= 4294967295
)
error_message = "bgp_settings.asn must be a valid ASN in the range 1-4294967295."
}
validation {
condition = var.bgp_settings == null || can(regex(
"^((25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])$",
var.bgp_settings.bgp_peering_address
))
error_message = "bgp_settings.bgp_peering_address must be a valid IPv4 address."
}
}
variable "tags" {
type = map(string)
default = {}
description = "Tags applied to the Local Network Gateway."
}
outputs.tf
output "id" {
description = "Resource ID of the Local Network Gateway (feed this to virtual_network_gateway_connection)."
value = azurerm_local_network_gateway.this.id
}
output "name" {
description = "Name of the Local Network Gateway."
value = azurerm_local_network_gateway.this.name
}
output "gateway_address" {
description = "Configured on-prem public IP (null when an FQDN peer is used)."
value = azurerm_local_network_gateway.this.gateway_address
}
output "gateway_fqdn" {
description = "Configured on-prem FQDN (null when a static IP peer is used)."
value = azurerm_local_network_gateway.this.gateway_fqdn
}
output "address_space" {
description = "CIDR ranges advertised as reachable behind the on-prem gateway."
value = azurerm_local_network_gateway.this.address_space
}
output "bgp_settings" {
description = "Effective BGP peering settings (asn, bgp_peering_address, peer_weight), or null."
value = one(azurerm_local_network_gateway.this.bgp_settings)
}
How to use it
A single branch site terminating an IPsec tunnel against an existing Azure VPN Gateway, with BGP enabled. The LNG’s id output is consumed downstream by the connection resource that actually builds the tunnel.
module "local_network_gateway" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-local-network-gateway?ref=v1.0.0"
name = "lng-mumbai-branch-cin-prod"
resource_group_name = azurerm_resource_group.network.name
location = azurerm_resource_group.network.location
gateway_address = "203.0.113.10" # public IP of the on-prem firewall
address_space = ["10.50.0.0/16", "10.51.0.0/16"]
bgp_settings = {
asn = 65010
bgp_peering_address = "10.50.255.254" # router's loopback, inside address_space
peer_weight = 0
}
tags = {
environment = "prod"
site = "mumbai-branch"
managed_by = "terraform"
}
}
# Downstream: build the actual S2S tunnel using the module's id output.
resource "azurerm_virtual_network_gateway_connection" "mumbai" {
name = "cn-mumbai-branch-prod"
resource_group_name = azurerm_resource_group.network.name
location = azurerm_resource_group.network.location
type = "IPsec"
virtual_network_gateway_id = azurerm_virtual_network_gateway.hub.id
local_network_gateway_id = module.local_network_gateway.id
shared_key = var.vpn_shared_key # pull from Key Vault, never hard-code
enable_bgp = true
connection_protocol = "IKEv2"
ipsec_policy {
dh_group = "DHGroup14"
ike_encryption = "AES256"
ike_integrity = "SHA256"
ipsec_encryption = "GCMAES256"
ipsec_integrity = "GCMAES256"
pfs_group = "PFS2048"
sa_lifetime = 27000
}
tags = {
environment = "prod"
site = "mumbai-branch"
}
}
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/local_network_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-local-network-gateway?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/local_network_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 | Name of the Local Network Gateway (validated 3-80 chars). |
resource_group_name |
string |
— | Yes | Resource group that contains the LNG. |
location |
string |
— | Yes | Azure region, usually matching the VPN Gateway. |
gateway_address |
string |
null |
Conditional | Public IPv4 of the on-prem VPN device. Mutually exclusive with gateway_fqdn. |
gateway_fqdn |
string |
null |
Conditional | FQDN of a dynamic-IP on-prem device. Mutually exclusive with gateway_address. |
address_space |
list(string) |
[] |
Conditional | CIDRs reachable behind the gateway. Required unless bgp_settings is set. |
bgp_settings |
object({ asn, bgp_peering_address, peer_weight }) |
null |
No | Optional BGP peering (peer ASN + peering IP + weight). |
tags |
map(string) |
{} |
No | Tags applied to the LNG. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the LNG; pass to azurerm_virtual_network_gateway_connection.local_network_gateway_id. |
name |
Name of the Local Network Gateway. |
gateway_address |
Configured on-prem public IP (null when an FQDN peer is used). |
gateway_fqdn |
Configured on-prem FQDN (null when a static IP peer is used). |
address_space |
CIDR ranges advertised as reachable behind the on-prem gateway. |
bgp_settings |
Effective BGP peering object (asn, bgp_peering_address, peer_weight), or null. |
Enterprise scenario
A retail group runs a hub VPN Gateway in Central India and connects 140 store sites over Site-to-Site VPN. Each store’s firewall has a different public IP and a distinct 10.x.0.0/16 behind it, so the platform team drives for_each over a YAML inventory and stamps one instance of this module per store — the validations catch the inevitable typo’d CIDR or RFC1918 “public” IP in PR review instead of at tunnel-up time. When a store’s ISP changes its IP, it is a one-line diff to gateway_address, and the module’s id output keeps the downstream azurerm_virtual_network_gateway_connection wired without touching the connection definition.
Best practices
- Pick IP or FQDN deliberately. Use
gateway_addressfor sites with a static public IP; usegateway_fqdnonly for peers behind dynamic IPs (DDNS). Setting both is invalid — this module forces exactly one. - Keep
address_spacetight and accurate. It defines what Azure routes over the tunnel; over-broad ranges (e.g.0.0.0.0/0) can blackhole traffic or create overlaps with the VNet. List only the prefixes that truly live on-prem, and never overlap them with your Azure VNet CIDRs. - Prefer BGP for multi-tunnel / active-active. When you have redundant tunnels or an active-active VPN Gateway, BGP avoids brittle static prefix lists and enables failover; ensure
bgp_peering_addressis a routable host inside the on-premaddress_spaceand the ASN matches the on-prem router (avoid reserved ASN 65515 used by Azure). - Never put the PSK here. The Local Network Gateway holds no secrets, but its partner
virtual_network_gateway_connectiondoes — sourceshared_keyfrom Key Vault, not from tfvars committed to git. - Name for the remote site, not the resource. A convention like
lng-<site>-<region>-<env>(e.g.lng-mumbai-branch-cin-prod) makes a 100+ LNG estate searchable and keeps Azure Policy / tagging consistent. - Cost is in the tunnel, not the LNG. The Local Network Gateway itself is free; charges accrue on the VPN Gateway SKU and S2S egress. Right-size the gateway SKU (Basic vs VpnGw1-5) to the aggregate tunnel count and throughput these LNGs imply, and decommission LNGs for retired sites so stale connections do not linger.