Quick take — Reusable hashicorp/azurerm ~> 4.0 module for Azure Virtual WAN: a Standard/Basic WAN plus var-driven virtual hubs, branch-to-branch routing and hub address-space validation for hub-and-spoke at global scale. 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 "virtual_wan" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-wan?ref=v1.0.0"
name = "..." # Name of the Virtual WAN (2-80 chars, starts alphanumeri…
resource_group_name = "..." # Resource group holding the WAN and its hubs.
location = "..." # Azure region for the WAN resource (hubs may live elsewh…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Virtual WAN is a managed networking service that bundles connectivity, routing and security into a single hub-and-spoke fabric. Instead of hand-building a transit VNet, peering it to every spoke, and bolting on a VPN gateway, an ExpressRoute gateway and a firewall, you create one azurerm_virtual_wan resource and attach virtual hubs to it — one per Azure region. Microsoft runs the routing plane; you just declare the topology. The WAN object itself is cheap and almost configuration-free, but it is the anchor that every hub, gateway, connection and routing intent references, so it deserves to be a small, stable, versioned module of its own.
Wrapping it in a reusable module matters because the WAN is a long-lived, shared, blast-radius-sensitive resource. The handful of toggles it exposes — type (Basic vs Standard), disable_vpn_encryption, allow_branch_to_branch_traffic, office365_local_breakout_category — quietly change the behaviour of every hub hanging off it. A module lets you set sane, reviewed defaults once (Standard SKU, branch-to-branch on, tags enforced), expose the WAN id as an output, and have every downstream hub/gateway module consume that single source of truth rather than each team minting its own WAN. This module provisions the WAN plus an optional set of virtual hubs so the “skeleton” of a global network lands in one terraform apply; the heavier per-hub resources (VPN/ER/P2S gateways, Azure Firewall, routing intent) stay in their own modules that reference these outputs.
When to use it
- You are building a global hub-and-spoke or “network-as-a-service” topology and want Microsoft to manage transit routing instead of maintaining a mesh of VNet peerings and a UDR-laden transit VNet.
- You need any-to-any connectivity between branches (VPN/SD-WAN), ExpressRoute circuits, point-to-site users and Azure VNets across multiple regions, with automatic route propagation.
- You are landing an Enterprise-Scale / CAF connectivity subscription and want the Virtual WAN flavour of the landing zone rather than the traditional hub-VNet flavour.
- You want Secured Virtual Hubs (Azure Firewall + routing intent) and need the Standard SKU WAN as the foundation that those features sit on.
- Skip this module if you only have one or two VNets in a single region — a plain hub VNet with peerings is cheaper. Virtual WAN bills per hub plus per-GB processed, so it pays off at scale, not for a single small footprint.
Module structure
terraform-module-azure-virtual-wan/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_virtual_wan + optional azurerm_virtual_hub(s)
├── variables.tf # var-driven inputs with validation
└── outputs.tf # WAN id/name + hub ids/names maps
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
resource "azurerm_virtual_wan" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
type = var.type
# Branch-to-branch lets two VPN/ExpressRoute branches talk through the WAN.
# Turn it off only when you deliberately want spoke isolation.
allow_branch_to_branch_traffic = var.allow_branch_to_branch_traffic
disable_vpn_encryption = var.disable_vpn_encryption
# Optimised routing for Microsoft 365 breakout categories.
office365_local_breakout_category = var.office365_local_breakout_category
tags = var.tags
}
# One virtual hub per region. The hub is what gateways, firewalls and
# VNet connections actually attach to; the WAN above just groups them.
resource "azurerm_virtual_hub" "this" {
for_each = var.virtual_hubs
name = each.value.name
resource_group_name = var.resource_group_name
location = each.value.location
virtual_wan_id = azurerm_virtual_wan.this.id
# /23 or larger is required by Azure; /24 leaves no room for gateways.
address_prefix = each.value.address_prefix
# Standard hubs support routing preference; default keeps ExpressRoute hot.
hub_routing_preference = each.value.hub_routing_preference
# Capacity is expressed in routing infrastructure units (1 unit = 2 Gbps,
# 1000 VMs). Bump for high-throughput hubs.
virtual_router_auto_scale_min_capacity = each.value.router_auto_scale_min_capacity
tags = merge(var.tags, each.value.tags)
}
variables.tf
variable "name" {
description = "Name of the Virtual WAN resource."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 2-80 chars and start with an alphanumeric character."
}
}
variable "resource_group_name" {
description = "Resource group that holds the WAN and its hubs."
type = string
}
variable "location" {
description = "Azure region for the Virtual WAN resource (hubs may differ)."
type = string
}
variable "type" {
description = "WAN SKU. Standard is required for hub-to-hub transit, ExpressRoute, NVA and Secured Hub features; Basic only supports site-to-site VPN."
type = string
default = "Standard"
validation {
condition = contains(["Basic", "Standard"], var.type)
error_message = "type must be either \"Basic\" or \"Standard\"."
}
}
variable "allow_branch_to_branch_traffic" {
description = "Allow traffic between two branches (VPN/ExpressRoute) to transit the WAN."
type = bool
default = true
}
variable "disable_vpn_encryption" {
description = "Disable IPsec encryption on VPN connections. Leave false for production."
type = bool
default = false
}
variable "office365_local_breakout_category" {
description = "Microsoft 365 local-breakout optimisation category."
type = string
default = "None"
validation {
condition = contains(["Optimize", "OptimizeAndAllow", "All", "None"], var.office365_local_breakout_category)
error_message = "Must be one of Optimize, OptimizeAndAllow, All, or None."
}
}
variable "virtual_hubs" {
description = "Map of virtual hubs to attach to the WAN, keyed by a short region alias. Hub CIDRs must not overlap each other or any connected VNet."
type = map(object({
name = string
location = string
address_prefix = string
hub_routing_preference = optional(string, "ExpressRoute")
router_auto_scale_min_capacity = optional(number, 2)
tags = optional(map(string), {})
}))
default = {}
validation {
condition = alltrue([
for h in values(var.virtual_hubs) :
tonumber(split("/", h.address_prefix)[1]) <= 23
])
error_message = "Each virtual hub address_prefix must be /23 or larger (Azure requires at least a /23)."
}
validation {
condition = alltrue([
for h in values(var.virtual_hubs) :
contains(["ExpressRoute", "VpnGateway", "ASPath"], h.hub_routing_preference)
])
error_message = "hub_routing_preference must be ExpressRoute, VpnGateway, or ASPath."
}
validation {
condition = alltrue([
for h in values(var.virtual_hubs) :
h.router_auto_scale_min_capacity >= 2
])
error_message = "router_auto_scale_min_capacity must be at least 2 routing infrastructure units."
}
}
variable "tags" {
description = "Tags applied to the WAN and merged into every hub."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Virtual WAN. Feed this into hub gateway, firewall and connection modules."
value = azurerm_virtual_wan.this.id
}
output "name" {
description = "Name of the Virtual WAN."
value = azurerm_virtual_wan.this.name
}
output "type" {
description = "SKU of the Virtual WAN (Basic or Standard)."
value = azurerm_virtual_wan.this.type
}
output "virtual_hub_ids" {
description = "Map of region alias => virtual hub resource ID."
value = { for k, h in azurerm_virtual_hub.this : k => h.id }
}
output "virtual_hub_names" {
description = "Map of region alias => virtual hub name."
value = { for k, h in azurerm_virtual_hub.this : k => h.name }
}
output "virtual_hub_default_route_table_ids" {
description = "Map of region alias => the hub's default route table ID, used when associating VNet connections."
value = { for k, h in azurerm_virtual_hub.this : k => h.default_route_table_id }
}
How to use it
module "virtual_wan" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-wan?ref=v1.0.0"
name = "vwan-kv-global-prod"
resource_group_name = azurerm_resource_group.connectivity.name
location = "centralindia"
type = "Standard"
allow_branch_to_branch_traffic = true
office365_local_breakout_category = "OptimizeAndAllow"
virtual_hubs = {
inc = {
name = "vhub-centralindia-prod"
location = "centralindia"
address_prefix = "10.100.0.0/23"
hub_routing_preference = "ExpressRoute"
router_auto_scale_min_capacity = 2
}
weu = {
name = "vhub-westeurope-prod"
location = "westeurope"
address_prefix = "10.101.0.0/23"
router_auto_scale_min_capacity = 3
}
}
tags = {
environment = "prod"
owner = "platform-networking"
costcenter = "cc-1042"
}
}
# Downstream: attach a spoke VNet to the Central India hub using the
# hub id and its default route table exported by the module.
resource "azurerm_virtual_hub_connection" "app_spoke" {
name = "conn-app-prod-to-inc"
virtual_hub_id = module.virtual_wan.virtual_hub_ids["inc"]
remote_virtual_network_id = azurerm_virtual_network.app_spoke.id
internet_security_enabled = true
routing {
associated_route_table_id = module.virtual_wan.virtual_hub_default_route_table_ids["inc"]
}
}
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/virtual_wan/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-wan?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/virtual_wan && 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 Virtual WAN (2-80 chars, starts alphanumeric). |
resource_group_name |
string |
— | Yes | Resource group holding the WAN and its hubs. |
location |
string |
— | Yes | Azure region for the WAN resource (hubs may live elsewhere). |
type |
string |
"Standard" |
No | WAN SKU; Standard enables hub-to-hub transit, ExpressRoute, NVA and Secured Hub. Basic is S2S-VPN only. |
allow_branch_to_branch_traffic |
bool |
true |
No | Allow branch-to-branch (VPN/ER) traffic to transit the WAN. |
disable_vpn_encryption |
bool |
false |
No | Disable IPsec on VPN connections; keep false in production. |
office365_local_breakout_category |
string |
"None" |
No | Microsoft 365 local-breakout optimisation: Optimize, OptimizeAndAllow, All, or None. |
virtual_hubs |
map(object) |
{} |
No | Map of region alias => hub config (name, location, address_prefix, optional hub_routing_preference, router_auto_scale_min_capacity, tags). CIDRs must be /23+ and non-overlapping. |
tags |
map(string) |
{} |
No | Tags applied to the WAN and merged into every hub. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Virtual WAN; feed into gateway/firewall/connection modules. |
name |
Name of the Virtual WAN. |
type |
SKU of the WAN (Basic or Standard). |
virtual_hub_ids |
Map of region alias => virtual hub resource ID. |
virtual_hub_names |
Map of region alias => virtual hub name. |
virtual_hub_default_route_table_ids |
Map of region alias => the hub’s default route table ID, for associating VNet connections. |
Enterprise scenario
A retail group running stores across India and Europe consolidates 40+ branch SD-WAN sites, two ExpressRoute circuits and a fleet of regional application spokes onto a single Standard Virtual WAN. The platform team deploys this module from their CAF connectivity subscription with two hubs — centralindia and westeurope — sized at /23 each, then layers Secured Virtual Hubs (Azure Firewall + routing intent) and VPN gateways on top via separate modules that consume virtual_wan.id and virtual_hub_ids. Branch-to-branch routing lets a store in Pune reach a warehouse VPN site in Germany without any manual UDRs, and the whole transit fabric is described in one reviewed Terraform module instead of a sprawl of hand-peered VNets.
Best practices
- Use Standard, not Basic, for anything real. Basic WAN only does site-to-site VPN — no hub-to-hub transit, no ExpressRoute, no NVA, no Secured Hub. Downgrading later forces a recreate, so start on Standard unless the cost of a single VPN-only hub is the explicit goal.
- Plan hub CIDRs centrally and never let them overlap. Each hub needs at least a /23, and it must not collide with any connected VNet or on-prem range. The module’s validation enforces /23+; pair it with an IPAM spreadsheet or
azurermIPAM pool so two teams never claim10.100.0.0/23twice. - Keep the WAN and hubs in the connectivity subscription, and keep this module thin. The WAN
idis referenced by gateways, firewalls and every spoke connection — treat it as a shared platform resource with restricted RBAC, and let heavier per-hub resources live in their own modules so a hub change never risks re-creating the WAN. - Right-size routing infrastructure units, because hubs bill hourly plus per-GB. Default 2 units (2 Gbps) is fine for most hubs; only raise
router_auto_scale_min_capacityon genuinely high-throughput regions, since every unit and every processed gigabyte adds to the bill. Tear down unused hubs — an idle hub still charges. - Decide branch-to-branch deliberately. Leaving
allow_branch_to_branch_traffic = trueenables any-to-any reachability, which is usually what you want; set it tofalsewhen compliance requires branch isolation, and document the choice because it silently changes every connection’s reachability. - Tag for cost allocation and ownership. Stamp
environment,ownerandcostcentervia thetagsinput so the WAN and all hubs roll up cleanly in Cost Management — Virtual WAN spend is easy to lose track of when hubs span multiple regions.