IaC Azure

Terraform Module: Azure Virtual WAN — one global transit hub for every region

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

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

TerraformAzureVirtual WANModuleIaC
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