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:
allow_virtual_network_access— whether traffic from the local VNet can reach the remote VNet (almost alwaystrue).allow_forwarded_traffic— whether traffic forwarded by a VM (e.g. an NVA/firewall) in the remote VNet is accepted; needed when traffic transits an appliance.allow_gateway_transit— set on the hub side: “spokes peered to me may use my VPN/ExpressRoute gateway.”use_remote_gateways— set on the spoke side: “use the remote (hub) VNet’s gateway for on-prem connectivity instead of having my own.”
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
- You run a hub-and-spoke landing zone and need each spoke peered to the hub with correct, symmetric, gateway-transit-aware peering — defined once and reviewed in a PR.
- You want to mesh a small number of VNets (e.g. shared-services VNet ↔ workload VNet) and never again ship a half-configured peering stuck in
Initiated. - You need regional or global peering (same region or cross-region) and want one consistent module that handles both, with the flags surfaced explicitly per side.
- You want peering to consume the
idandnameoutputs of your VNet module so the whole spoke — VNet plus its hub peering — lands in a single reviewed change.
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 = trueif 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 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/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
- Always create both directions — this module does it for you. A peering is
Connectedonly when thea -> bandb -> alinks both exist; never hand-roll a singleazurerm_virtual_network_peeringand wonder why traffic won’t flow. - Get the gateway-transit pairing right. In hub/spoke, set
allow_gateway_transit = trueon the hub direction anduse_remote_gateways = trueon the spoke direction — never both on the same side, and neveruse_remote_gatewayson both sides. The module’s validations catch these mistakes at plan time. - Enable
allow_forwarded_trafficonly when traffic transits an appliance. If the hub runs an NVA or Azure Firewall that forwards spoke-to-spoke traffic, the receiving direction needsallow_forwarded_traffic = true; leave it off for plain VNet-to-VNet peering. - Confirm the hub gateway exists before peering.
use_remote_gatewaysfails unless the remote (hub) VNet has a deployed VPN/ExpressRoute gateway, and a spoke that already has its own gateway cannot consume a remote one — plan the gateway topology first. - Mind non-overlapping address spaces. Peering does not work between VNets with overlapping CIDRs; allocate ranges from a central IPAM so hub and every spoke are disjoint before you wire the peering.
- Pin the module and name by convention. Use
?ref=<tag>so a topology change is deliberate, name peerings<src>-to-<dst>for readability in the portal, and feed the VNet module’sid/nameoutputs straight into this module so a spoke and its hub peering ship in one reviewed PR.