Skip to main content

Building a Hub-and-Spoke Sandbox on Azure Virtual WAN with Terraform

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.

Architecture diagram: management group down to subscription, hub resource group with two virtual hubs, and two spoke resource groups each with a VNET and test VM

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 plan on PR — this currently runs from a local machine with az login, which is fine for a sandbox but not a habit worth keeping

View full source on GitHub →