IaC Azure

Terraform Module: Azure Virtual Network Peering — Symmetric, Both-Direction Peering Done in One Call

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_virtual_network_peering that creates BOTH directions of a peering at once, with first-class support for the hub/spoke gateway-transit pattern. 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 "peering" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-network-peering?ref=v1.0.0"

  name_prefix = "..."   # Short label for both peering names, e.g. hub-to-payments.

  vnet_a_name                = "..."  # Name of the first VNet (the "a" side).
  vnet_a_resource_group_name = "..."  # Resource group of the first VNet.
  vnet_a_id                  = "..."  # Full resource ID of the first VNet.

  vnet_b_name                = "..."  # Name of the second VNet (the "b" side).
  vnet_b_resource_group_name = "..."  # Resource group of the second VNet.
  vnet_b_id                  = "..."  # Full resource ID of the second VNet.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Virtual Network Peering (azurerm_virtual_network_peering) connects two VNets so resources in each can reach the other over the Azure backbone using private IPs — no VPN gateway, no public hops, low latency. The crucial detail that trips up almost everyone the first time is that peering is two one-way links, not one two-way link. A peering is Connected only when both sides exist: a link from VNet A to VNet B, and a matching link from VNet B back to VNet A. Create only one direction and the portal shows the peering stuck in Initiated — traffic never flows.

That asymmetry is exactly the kind of thing a module should hide. This module takes the two VNets (each as a name, resource group, and resource ID) and creates both azurerm_virtual_network_peering resources — a -> b and b -> a — in a single call, so a peering is always symmetric by construction. You can never accidentally ship half a peering.

It also exposes the four behaviour flags that matter, per direction, because the most important Azure topology — hub-and-spoke with gateway transit — is deliberately asymmetric:

In the classic hub/spoke pattern the hub peering sets allow_gateway_transit = true and the spoke peering sets use_remote_gateways = true, so every spoke reaches on-prem through the hub’s single shared gateway instead of each spoke paying for its own. This module lets you set those flags independently on the a and b directions, so one module call wires a spoke to a hub correctly — both directions, gateway transit and all.

When to use it

Reach for Azure Virtual Network Manager (AVNM) connectivity configurations instead when you operate at a scale where you want centrally-managed, auto-meshed connectivity across dozens of VNets and subscriptions. For a handful of explicit hub/spoke links, a peering module you can read in one sitting is the better fit.

Module structure

terraform-module-azure-virtual-network-peering/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # both peering directions (a->b and b->a)
├── variables.tf     # per-VNet inputs + per-direction flags
└── outputs.tf       # both peering ids and names

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  # Derive deterministic peering names from the prefix unless explicit names
  # are supplied. Azure shows these names in the portal on each VNet.
  peering_a_to_b_name = coalesce(var.peering_a_to_b_name, "${var.name_prefix}-a-to-b")
  peering_b_to_a_name = coalesce(var.peering_b_to_a_name, "${var.name_prefix}-b-to-a")
}

# Direction 1: from VNet A, pointing at VNet B. Lives in A's resource group
# and is scoped to A's virtual network.
resource "azurerm_virtual_network_peering" "a_to_b" {
  name                      = local.peering_a_to_b_name
  resource_group_name       = var.vnet_a_resource_group_name
  virtual_network_name      = var.vnet_a_name
  remote_virtual_network_id = var.vnet_b_id

  allow_virtual_network_access = var.a_to_b.allow_virtual_network_access
  allow_forwarded_traffic      = var.a_to_b.allow_forwarded_traffic
  allow_gateway_transit        = var.a_to_b.allow_gateway_transit
  use_remote_gateways          = var.a_to_b.use_remote_gateways
}

# Direction 2: from VNet B, pointing back at VNet A. A peering is only
# `Connected` when BOTH directions exist — this is why the module always
# creates the pair together.
resource "azurerm_virtual_network_peering" "b_to_a" {
  name                      = local.peering_b_to_a_name
  resource_group_name       = var.vnet_b_resource_group_name
  virtual_network_name      = var.vnet_b_name
  remote_virtual_network_id = var.vnet_a_id

  allow_virtual_network_access = var.b_to_a.allow_virtual_network_access
  allow_forwarded_traffic      = var.b_to_a.allow_forwarded_traffic
  allow_gateway_transit        = var.b_to_a.allow_gateway_transit
  use_remote_gateways          = var.b_to_a.use_remote_gateways
}

variables.tf

variable "name_prefix" {
  description = "Short label used to derive both peering names (e.g. hub-to-payments) when explicit names are not set."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,72}$", var.name_prefix))
    error_message = "name_prefix must start with an alphanumeric and contain only letters, digits, periods, underscores, and hyphens."
  }
}

variable "peering_a_to_b_name" {
  description = "Explicit name for the A->B peering. Null derives \"<name_prefix>-a-to-b\"."
  type        = string
  default     = null
}

variable "peering_b_to_a_name" {
  description = "Explicit name for the B->A peering. Null derives \"<name_prefix>-b-to-a\"."
  type        = string
  default     = null
}

# ---- VNet A (the "a" side) ----
variable "vnet_a_name" {
  description = "Name of the first VNet (the A side)."
  type        = string
}

variable "vnet_a_resource_group_name" {
  description = "Resource group containing VNet A."
  type        = string
}

variable "vnet_a_id" {
  description = "Full Azure resource ID of VNet A (used as remote_virtual_network_id on the B->A peering)."
  type        = string

  validation {
    condition     = can(regex("^/subscriptions/.+/providers/Microsoft.Network/virtualNetworks/.+$", var.vnet_a_id))
    error_message = "vnet_a_id must be a virtual network resource ID."
  }
}

# ---- VNet B (the "b" side) ----
variable "vnet_b_name" {
  description = "Name of the second VNet (the B side)."
  type        = string
}

variable "vnet_b_resource_group_name" {
  description = "Resource group containing VNet B."
  type        = string
}

variable "vnet_b_id" {
  description = "Full Azure resource ID of VNet B (used as remote_virtual_network_id on the A->B peering)."
  type        = string

  validation {
    condition     = can(regex("^/subscriptions/.+/providers/Microsoft.Network/virtualNetworks/.+$", var.vnet_b_id))
    error_message = "vnet_b_id must be a virtual network resource ID."
  }
}

# ---- Per-direction behaviour flags ----
# For a hub/spoke link set the HUB side allow_gateway_transit = true and the
# SPOKE side use_remote_gateways = true; never set both on the same direction.
variable "a_to_b" {
  description = <<-EOT
    Flags for the A->B peering:
      allow_virtual_network_access - traffic from A can reach B (default true)
      allow_forwarded_traffic      - accept traffic forwarded by an NVA in B (default false)
      allow_gateway_transit        - A shares its gateway with B (hub side, default false)
      use_remote_gateways          - A uses B's gateway for on-prem (spoke side, default false)
  EOT
  type = object({
    allow_virtual_network_access = optional(bool, true)
    allow_forwarded_traffic      = optional(bool, false)
    allow_gateway_transit        = optional(bool, false)
    use_remote_gateways          = optional(bool, false)
  })
  default = {}

  validation {
    condition     = !(var.a_to_b.allow_gateway_transit && var.a_to_b.use_remote_gateways)
    error_message = "On a single direction, allow_gateway_transit and use_remote_gateways cannot both be true."
  }
}

variable "b_to_a" {
  description = <<-EOT
    Flags for the B->A peering (see a_to_b for field meanings). For hub/spoke,
    typically the hub direction sets allow_gateway_transit = true and the spoke
    direction sets use_remote_gateways = true.
  EOT
  type = object({
    allow_virtual_network_access = optional(bool, true)
    allow_forwarded_traffic      = optional(bool, false)
    allow_gateway_transit        = optional(bool, false)
    use_remote_gateways          = optional(bool, false)
  })
  default = {}

  validation {
    condition     = !(var.b_to_a.allow_gateway_transit && var.b_to_a.use_remote_gateways)
    error_message = "On a single direction, allow_gateway_transit and use_remote_gateways cannot both be true."
  }

  validation {
    # Gateway transit is meaningful only when paired: one side transits, the
    # other consumes. Guard against the common "both consume" misconfiguration.
    condition     = !(var.a_to_b.use_remote_gateways && var.b_to_a.use_remote_gateways)
    error_message = "Both directions cannot set use_remote_gateways = true; only the spoke side should."
  }
}

outputs.tf

output "a_to_b_id" {
  description = "Resource ID of the A->B peering."
  value       = azurerm_virtual_network_peering.a_to_b.id
}

output "a_to_b_name" {
  description = "Name of the A->B peering."
  value       = azurerm_virtual_network_peering.a_to_b.name
}

output "b_to_a_id" {
  description = "Resource ID of the B->A peering."
  value       = azurerm_virtual_network_peering.b_to_a.id
}

output "b_to_a_name" {
  description = "Name of the B->A peering."
  value       = azurerm_virtual_network_peering.b_to_a.name
}

output "peering_ids" {
  description = "Both peering IDs keyed by direction, for diagnostics and locks."
  value = {
    a_to_b = azurerm_virtual_network_peering.a_to_b.id
    b_to_a = azurerm_virtual_network_peering.b_to_a.id
  }
}

How to use it

# Hub/spoke: peer a payments spoke to the connectivity hub. The hub direction
# shares its gateway; the spoke direction consumes it for on-prem connectivity.
module "hub_to_payments" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-network-peering?ref=v1.0.0"

  name_prefix = "hub-payments"

  # A side = the connectivity HUB (owns the VPN/ExpressRoute gateway).
  vnet_a_name                = module.hub_vnet.name
  vnet_a_resource_group_name = module.hub_rg.name
  vnet_a_id                  = module.hub_vnet.id

  # B side = the payments SPOKE.
  vnet_b_name                = module.payments_vnet.name
  vnet_b_resource_group_name = module.payments_rg.name
  vnet_b_id                  = module.payments_vnet.id

  # Hub shares its gateway and forwards NVA/firewall traffic to the spoke.
  a_to_b = {
    allow_virtual_network_access = true
    allow_forwarded_traffic      = true
    allow_gateway_transit        = true
    use_remote_gateways          = false
  }

  # Spoke uses the hub's gateway for on-prem; it has no gateway of its own.
  b_to_a = {
    allow_virtual_network_access = true
    allow_forwarded_traffic      = false
    allow_gateway_transit        = false
    use_remote_gateways          = true
  }
}

# Plain mesh peering (no gateways): two workload VNets that just need to talk.
module "shared_to_analytics" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-network-peering?ref=v1.0.0"

  name_prefix = "shared-analytics"

  vnet_a_name                = module.shared_vnet.name
  vnet_a_resource_group_name = module.shared_rg.name
  vnet_a_id                  = module.shared_vnet.id

  vnet_b_name                = module.analytics_vnet.name
  vnet_b_resource_group_name = module.analytics_rg.name
  vnet_b_id                  = module.analytics_vnet.id

  # Defaults are fine: allow_virtual_network_access = true, everything else off.
}

Gateway transit only works if the hub actually has a deployed VPN or ExpressRoute gateway, and a spoke cannot set use_remote_gateways = true if it already has a gateway of its own — Azure rejects that combination at apply time.

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/peering/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-network-peering?ref=v1.0.0"
}

inputs = {
  name_prefix = "..."
  vnet_a_name = "..."
  vnet_a_resource_group_name = "..."
  vnet_a_id = "..."
  vnet_b_name = "..."
  vnet_b_resource_group_name = "..."
  vnet_b_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/peering && 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_prefix string Yes Label used to derive both peering names.
peering_a_to_b_name string null No Explicit name for the A→B peering.
peering_b_to_a_name string null No Explicit name for the B→A peering.
vnet_a_name string Yes Name of VNet A.
vnet_a_resource_group_name string Yes Resource group of VNet A.
vnet_a_id string Yes Full resource ID of VNet A.
vnet_b_name string Yes Name of VNet B.
vnet_b_resource_group_name string Yes Resource group of VNet B.
vnet_b_id string Yes Full resource ID of VNet B.
a_to_b object {} No Flags for the A→B direction (access/forwarded/gateway_transit/remote_gateways).
b_to_a object {} No Flags for the B→A direction.

a_to_b / b_to_a object

Field Type Default Description
allow_virtual_network_access bool true Local VNet can reach the remote VNet.
allow_forwarded_traffic bool false Accept traffic forwarded by an NVA in the remote VNet.
allow_gateway_transit bool false Share this VNet’s gateway (hub side).
use_remote_gateways bool false Use the remote VNet’s gateway (spoke side).

Outputs

Name Description
a_to_b_id Resource ID of the A→B peering.
a_to_b_name Name of the A→B peering.
b_to_a_id Resource ID of the B→A peering.
b_to_a_name Name of the B→A peering.
peering_ids Map of both peering IDs keyed by direction.

Enterprise scenario

A fintech platform runs a hub-and-spoke landing zone in centralindia: a connectivity hub VNet with a single ExpressRoute gateway, and a growing set of workload spokes (payments, ledger, analytics). Every spoke is peered to the hub with this module — one call per spoke, always creating both directions so no peering is ever stuck in Initiated. The hub direction sets allow_gateway_transit = true and allow_forwarded_traffic = true (so the central Azure Firewall can route between spokes), while each spoke direction sets use_remote_gateways = true, letting every spoke reach on-prem through the hub’s one shared ExpressRoute circuit instead of paying for a gateway each. When the payments team stands up a new spoke, their PR adds a module "virtual_network" block and a module "..._peering" block that consumes the new VNet’s id output — the spoke comes online fully connected to the hub and on-prem in a single reviewed change, with the module’s validation preventing the classic “both sides use_remote_gateways” misconfiguration.

Best practices

TerraformAzureVirtual Network PeeringModuleIaC
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