Building Enterprise Hub-and-Spoke on Azure with Terraform
Hub-and-Spoke is the gold standard for enterprise Azure network architecture. After designing and implementing this pattern at Revantage Asia and across Accenture client engagements, I've distilled everything into a repeatable, Terraform-automated blueprint.
Why Hub-and-Spoke?
When you're managing dozens of workloads across multiple teams, flat network designs collapse fast. Hub-and-Spoke solves:
- Centralized security inspection via Azure Firewall
- Shared services (DNS, Bastion, VPN) without duplication
- Blast radius isolation — a compromised Spoke doesn't touch others
- Cost optimization — centralized egress vs per-spoke gateways
Architecture Overview
HUB VNet (10.0.0.0/16)
├── AzureFirewallSubnet → Azure Firewall Premium
├── GatewaySubnet → VPN/ExpressRoute Gateway
├── AzureBastionSubnet → Bastion Host
└── DNSResolverSubnet → Private DNS Resolver
SPOKE 1 (10.1.0.0/16) - Production
├── AppSubnet → AKS / App Service
└── DataSubnet → SQL / Cosmos DB (private endpoints)
SPOKE 2 (10.2.0.0/16) - Non-Production
└── ...
SPOKE 3 (10.3.0.0/16) - Shared Services
├── ACRSubnet → Azure Container Registry
└── KeyVaultSubnet → Key Vault (private endpoint)
Terraform Module Structure
I organize the IaC into discrete modules with clear interfaces:
# modules/hub-vnet/main.tf
resource "azurerm_virtual_network" "hub" {
name = "vnet-hub-${var.environment}-${var.location_short}"
location = var.location
resource_group_name = var.resource_group_name
address_space = var.address_space
tags = merge(var.tags, {
NetworkRole = "Hub"
ManagedBy = "Terraform"
})
}
resource "azurerm_subnet" "firewall" {
name = "AzureFirewallSubnet" # Must be exactly this name
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = [var.firewall_subnet_prefix]
}
resource "azurerm_firewall" "hub" {
name = "afw-hub-${var.environment}"
location = var.location
resource_group_name = var.resource_group_name
sku_name = "AZFW_VNet"
sku_tier = "Premium" # Required for IDPS, TLS inspection
ip_configuration {
name = "configuration"
subnet_id = azurerm_subnet.firewall.id
public_ip_address_id = azurerm_public_ip.firewall.id
}
}
VNet Peering: The Critical Part
Peering is not transitive — Spoke-to-Spoke traffic MUST route through the Hub:
# modules/spoke-vnet/peering.tf
resource "azurerm_virtual_network_peering" "spoke_to_hub" {
name = "peer-${var.spoke_name}-to-hub"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.spoke.name
remote_virtual_network_id = var.hub_vnet_id
allow_virtual_network_access = true
allow_forwarded_traffic = true
allow_gateway_transit = false # Spokes don't own the gateway
use_remote_gateways = true # Use hub's VPN gateway
}
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
name = "peer-hub-to-${var.spoke_name}"
resource_group_name = var.hub_resource_group_name
virtual_network_name = var.hub_vnet_name
remote_virtual_network_id = azurerm_virtual_network.spoke.id
allow_virtual_network_access = true
allow_forwarded_traffic = true
allow_gateway_transit = true # Hub owns and shares the gateway
use_remote_gateways = false
}
Forcing All Egress Through Firewall
Route tables are how you enforce traffic flow — not optional:
resource "azurerm_route_table" "spoke_default" {
name = "rt-${var.spoke_name}-default"
location = var.location
resource_group_name = var.resource_group_name
disable_bgp_route_propagation = true # Don't let BGP override our routes
route {
name = "default-to-firewall"
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = var.firewall_private_ip
}
}
Security Guardrails with Azure Policy
Never rely on teams "doing the right thing" — enforce it:
resource "azurerm_policy_assignment" "no_public_ip" {
name = "deny-public-ip"
scope = var.spoke_subscription_id
policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/..."
parameters = jsonencode({
effect = { value = "Deny" }
})
}
resource "azurerm_policy_assignment" "require_private_endpoints" {
name = "require-private-endpoint-storage"
scope = var.spoke_subscription_id
policy_definition_id = data.azurerm_policy_definition.pe_storage.id
enforce = true
}
The GitLab CI Pipeline
Every change goes through security gates before terraform apply:
stages:
- validate
- security
- plan
- apply
terraform:validate:
stage: validate
script:
- terraform init -backend=false
- terraform validate
- terraform fmt -check -recursive
checkov:scan:
stage: security
image: bridgecrew/checkov:latest
script:
- checkov -d . --framework terraform --compact --quiet
allow_failure: false
terraform:plan:
stage: plan
script:
- terraform init
- terraform plan -out=tfplan -detailed-exitcode
artifacts:
paths: [tfplan]
terraform:apply:
stage: apply
when: manual
script:
- terraform apply tfplan
environment: production
Lessons from the Field
After doing this across 3 enterprise clients:
- Always use
Premiumtier Firewall — Standard tier lacks IDPS and TLS inspection critical for PCI/SOC2 - DNS is the pain point — Private DNS Resolver with forwarding rules saves days of debugging
- Tag your route tables religiously — Debugging misrouted traffic without tags is hell
- Automate peering cleanup — Stale peerings from decommissioned spokes create confusion
- Budget alerts at the Spoke level — Hub costs are shared; Spoke-level chargeback drives accountability
The full Terraform module library is open-sourced at github.com/suhail39ahmed/azure-hub-spoke-terraform