cd ../blog
#Azure#Terraform#Networking#IaC#Hub-and-Spoke

Building Enterprise Hub-and-Spoke on Azure with Terraform

November 15, 202512 min readSuhail Ahmed Inayathulla

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:

  1. Always use Premium tier Firewall — Standard tier lacks IDPS and TLS inspection critical for PCI/SOC2
  2. DNS is the pain point — Private DNS Resolver with forwarding rules saves days of debugging
  3. Tag your route tables religiously — Debugging misrouted traffic without tags is hell
  4. Automate peering cleanup — Stale peerings from decommissioned spokes create confusion
  5. 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