IaC Azure

Terraform Module: Azure Local Network Gateway — Model Your On-Prem Edge as Code

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

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

TerraformAzureLocal Network 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