The scenario
A sandbox environment for testing Azure Virtual WAN routing — two spokes, each connected to its own virtual hub, each hub with its own route table. Enough moving parts (management groups, VNETs, a Virtual WAN, two hubs, hub connections, and test VMs) that hand-clicking it in the portal isn’t something I wanted to repeat, so it’s all Terraform.
One deliberate wrinkle worth calling out: VNET-HUB — the plain virtual network sitting
alongside the Virtual WAN resource — is provisioned but not connected to either virtual
hub. Only the two spoke VNETs get a azurerm_virtual_hub_connection. Easy to miss if you’re
skimming the resource list and assume every VNET in a hub-and-spoke diagram is, well, connected.
Why two hubs instead of one
The obvious design is a single virtual hub with both spokes plugged into it. I split it into
two — one hub per spoke, each with its own address prefix carved out of the same /23
(10.0.0.0/24 and 10.0.1.0/24) and its own route table — because the thing actually being
tested here is routing behavior between hubs, not just spoke-to-hub connectivity. A single
shared hub collapses that distinction; two hubs force the traffic across an inter-hub path,
which is the more interesting case to validate.
variable "vhub1_address_prefix" {
description = "Hub private address space for VNET-HUB-001 (dedicated to Spoke-001)."
type = string
default = "10.0.0.0/24"
}
variable "vhub2_address_prefix" {
description = "Hub private address space for VNET-HUB-002 (dedicated to Spoke-002)."
type = string
default = "10.0.1.0/24"
}
variable "vhub_sku" {
description = "Virtual WAN type (Standard/Basic). Standard is required for hub routing/route tables."
type = string
default = "Standard"
}
Standard isn’t the default SKU choice for Virtual WAN — Basic is cheaper — but Basic
doesn’t support custom hub route tables at all, and route tables are the entire point of
having two hubs here.
Module layout
Four modules, wired together in main.tf:
.
├── main.tf # wires the 4 modules together
├── variables.tf # all configurable inputs
├── outputs.tf # management group / vwan / hub / route table / vnet / vm IDs
├── providers.tf # azurerm provider, subscription_id
├── versions.tf # terraform >= 1.7.0, azurerm ~> 4.0
├── terraform.tfvars.example # copy to terraform.tfvars and fill in
└── modules/
├── management_group/ # azurerm_management_group, subscription association
├── resource_groups/ # RG-HUB, RG-Spoke-001, RG-Spoke-002
├── networking/ # VNETs + subnets, Virtual WAN, 2x virtual hub,
│ # 2x hub route table, 2x hub connection
└── compute/ # test VMs, NICs, NSGs, public IPs (one per spoke)
Each module takes its inputs explicitly rather than reaching into a shared state — networking
takes resource group names/locations as variables rather than a module reference, so it stays
usable on its own if I want to reuse it in a different root config later:
module "networking" {
source = "./modules/networking"
hub_rg_name = module.resource_groups.hub.name
hub_location = module.resource_groups.hub.location
spoke1_rg_name = module.resource_groups.spoke1.name
spoke1_location = module.resource_groups.spoke1.location
spoke2_rg_name = module.resource_groups.spoke2.name
spoke2_location = module.resource_groups.spoke2.location
tags = var.tags
hub_vnet_address_space = var.hub_vnet_address_space
hub_subnet_prefix = var.hub_subnet_prefix
spoke1_vnet_address_space = var.spoke1_vnet_address_space
spoke1_subnet_prefix = var.spoke1_subnet_prefix
spoke2_vnet_address_space = var.spoke2_vnet_address_space
spoke2_subnet_prefix = var.spoke2_subnet_prefix
vhub1_address_prefix = var.vhub1_address_prefix
vhub2_address_prefix = var.vhub2_address_prefix
vhub_sku = var.vhub_sku
}
compute depends on networking’s output subnet IDs, and both depend on resource_groups —
Terraform resolves that ordering from the references alone, no explicit depends_on needed
anywhere in main.tf.
The management group placement
This is the part that’s easy to forget when a sandbox subscription already exists: moving it under a dedicated management group is itself a resource, not a portal checkbox you do once and forget.
variable "parent_management_group_id" {
description = "Fully-qualified ID of the parent management group. Leave null to create directly under the Tenant Root Group."
type = string
default = null
}
Leaving parent_management_group_id as null creates “Sandbox” directly under the Tenant
Root Group. If your org already has an intermediate MG structure (a “Non-Production” MG, say),
that variable is where it plugs in — no code change needed, just the ID.
The identity running terraform apply needs Management Group Contributor at the Tenant
Root Group scope to do this part, which is a step above what most subscription-scoped CI/CD
service principals are granted by default. Worth flagging early if this runs in a pipeline —
better to hit that permissions error during the design conversation than in Actions/DevOps logs.
Test VMs and the RDP rule
Two Windows VMs, one per spoke, exist purely to ping across the hub-to-hub path and confirm
routing actually works the way the route tables say it should:
variable "allow_rdp_source" {
description = "Source address prefix allowed to RDP (TCP/3389) into the test VMs. Restrict this to your own IP/CIDR in real use."
type = string
default = "*"
}
Defaulting to * mirrors an “Any” RDP rule from the original design doc, and it’s fine for a
genuinely throwaway sandbox that gets torn down after the routing test — but it’s the one
variable in this whole config I’d never leave at its default anywhere longer-lived. If you
copy this pattern, override it with your own IP/CIDR the moment the sandbox stops being
disposable.
The admin password has no default at all:
variable "admin_password" {
description = "Local administrator password for the Windows test VMs. Supply via TF_VAR_admin_password or a tfvars file kept out of source control."
type = string
sensitive = true
}
sensitive = true keeps it out of plan/apply output, but that’s a UI courtesy, not encryption
— it still lands in state in plaintext. Fine for a sandbox with remote state access already
locked down; not a pattern to carry into anything where state might be read by more people
than should see that password.
Provider and version pinning
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
~> 4.0 pins to the 4.x line of the AzureRM provider — deliberate, since 4.0 changed several
resource defaults (including some networking resource behaviors) from the 3.x line. Worth
being explicit about the major version boundary rather than leaving it open, given how much
shifted in that release.
What I’d add next
- A third spoke to actually exercise the “does hub-to-hub routing scale past two” question — right now the two-hub design is set up for exactly two spokes and nothing more
- Azure Firewall in the hub — right now traffic between hubs is unfiltered; a secured virtual hub would be the next realistic step if this stopped being a routing-only sandbox
- A CI pipeline running
terraform planon PR — this currently runs from a local machine withaz login, which is fine for a sandbox but not a habit worth keeping