Brewing a Secure Hub-and-Spoke in Azure - Foundations

Foundations First: Why this network pattern still holds its crown

Do you really need another hub-and-spoke analogy? Probably not, but in enterprise networking, this pattern is as reliable as your local café’s flat white. It’s structured, scalable, and when brewed with security in mind, it keeps both auditors and architects happy.

What is the Hub-and-Spoke Model?

In Azure, the hub-and-spoke network topology is a way of organising and securing workloads:

  • Hub: Centralised network services such as firewalls, VPNs, and shared services (DNS, logging, identity).
  • Spokes: Workload VNets for applications, databases, or teams.
  • Peering: Low-latency, high-bandwidth connections between hub and spokes. Traffic is controlled centrally.

It’s essentially the espresso machine at the heart of your café: the spokes (applications) don’t each need their own machine — they share the central hub.

Why Hub-and-Spoke Remains the Go-To

Despite newer designs like full mesh and service chaining, hub-and-spoke continues to be the enterprise default because:

  • Centralised security: Easier to enforce policies (firewalls, monitoring) at a single control point.
  • Cost management: No need to duplicate network appliances in every spoke.
  • Operational simplicity: North-south internet traffic routed through one perimeter.
  • Flexibility: Spokes can scale independently, without dragging security along for the ride.

Core Components

A secure hub-and-spoke isn’t just about wiring up VNets — it’s about aligning platform services with security design principles.

  • Virtual WAN vs Traditional Hub

    • Traditional Hub: Build your own hub VNet with Azure Firewall, VPN Gateway, etc. High control, higher management overhead.
    • Azure Virtual WAN: Microsoft-managed global transit network. Easier to scale, great for multi-region and branch connectivity, but less granular control.
  • Network Security Groups (NSGs)

    • Used for basic subnet or NIC-level filtering. Best for east-west microsegmentation, not a replacement for a firewall.
  • User Defined Routes (UDRs)

    • Steer traffic through security appliances (e.g. Azure Firewall in the hub).
  • Azure Firewall (Recommended over NVAs)

    • Enterprise-grade, cloud-native firewall. Central control point for north-south and east-west flows. Goes far beyond NSGs.
🍺
Brewed Insight:

Is Azure Firewall the right solution for you? This is a question I get a lot. Azure Firewall is still a relatively young player in the next‑gen firewall space. Compared to NVAs from vendors like Palo Alto, Fortinet, or Check Point, it lacks some of the deep integrations and polish those platforms have built up over decades.

But here’s the kicker: if all you’re after is solid layer 4 and layer 7 control, centralised logging, and cloud‑native scale without heavy user-level integrations then Azure Firewall is often more than enough.

As features continue to evolve (Premium SKUs especially), the gap is closing fast. Just make sure you line up the feature set with your actual requirements before jumping in, rather than assuming “firewall is firewall”.

Security Design Principles

If you want your hub-and-spoke to hold up under scrutiny, ground it in these security concepts:

  • Least Privilege Networking
    • Don’t rely on “allow all VNet” rules. Be deliberate with NSGs and firewall rules.
  • Zero Trust Alignment
    • Every spoke should authenticate and authorise. Assume compromise within the network.
  • Segmentation
    • Keep dev, test, and prod in separate spokes — and don’t forget service-level segmentation (e.g. databases isolated from front-end tiers).

Architecture Diagram

Here’s a high-level look at a secure hub-and-spoke built with Azure Firewall:

graph TD Internet --> |North-South| AzureFirewall AzureFirewall --> HubVNet[Hub VNet] HubVNet --> Spoke1[Spoke VNet 1 - App Layer] HubVNet --> Spoke2[Spoke VNet 2 - Data Layer] HubVNet --> Spoke3[Spoke VNet 3 - Shared Services] Spoke1 <--> Spoke2 Spoke1 <--> Spoke3

Implementation Examples

Azure Portal (Quick Setup)

  1. Create Hub VNet with subnets: AzureFirewallSubnet, GatewaySubnet, SharedServices.
  2. Deploy Azure Firewall into AzureFirewallSubnet.
  3. Create Spoke VNets for workloads.
  4. Peer each Spoke VNet with the Hub VNet (enable “Use remote gateway” for spoke).
  5. Assign UDRs in spokes to route 0.0.0.0/0 to the firewall’s private IP.
  6. Apply NSGs for subnet-level control.

Bicep Example — Hub n Spoke using Bicep Modules

I often see folks build the first hub-and-spoke through the Azure Portal for speed, then later scramble to retrofit Bicep/ARM. Don’t, set the Bicep up front. Even if you start simple, you’ll thank yourself later when you need to scale hubs to multiple regions or automate deployments for dev/test/prod.

1
2
3
4
5
6
7
8
/bicep
  ├─ main.bicep              # Entry point
  ├─ modules/
  |   ├─ azfw.bicep          # Azure Firewall
  |   ├─ azfwpolicy.bicep    # Azure Firewall Policy
  │   ├─ hub.bicep           # Hub VNet + Firewall Subnet
  │   ├─ spoke.bicep         # Spoke VNet + NSG + UDR
  │   └─ peering.bicep       # Hub-Spoke peering

🔹 main.bicep — Root deployment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
param location string = resourceGroup().location

// Hub parameters
param hubVnetName string = 'hub-vnet'
param hubAddressPrefix string = '10.0.0.0/16'

// Firewall Policy parameters
param firewallpolicyName string = 'azfwpolicy-hub'

// Firewall
param firewallName string = 'azfw-hub'

// Spoke parameters
param spokeVnetName string = 'spoke-vnet1'
param spokeAddressPrefix string = '10.1.0.0/16'

// Deploy Hub
module hub './modules/hub.bicep' = {
  name: 'hubDeployment'
  params: {
    location: location
    hubVnetName: hubVnetName
    hubAddressPrefix: hubAddressPrefix
    firewallName: firewallName
  }
}

// Deploy Spoke with Route Table and NSG
module spoke './modules/spoke.bicep' = {
  name: 'spokeDeployment'
  params: {
    location: location
    spokeVnetName: spokeVnetName
    spokeAddressPrefix: spokeAddressPrefix
    firewallPrivateIp: hub.outputs.firewallPrivateIp
  }
}

// Deploy Peering
module peering './modules/peering.bicep' = {
  name: 'peeringDeployment'
  params: {
    hubVnetName: hubVnetName
    spokeVnetName: spokeVnetName
  }
}

// Deploy Azure Firewall Policy
module azfwPolicy './modules/azfwpolicy.bicep' = {
  name: 'azfwPolicyDeployment'
  params: {
    location: location
    firewallpolicyName: firewallpolicyName
  }
}

// Deploy Azure Firewall
module azfw './modules/azfw.bicep' = {
  name: 'azfwDeployment'
  params: {
    location: location
    firewallName: firewallName
    vnetName: hub.outputs.hubVnetId
    firewallPolicyId: azfwPolicy.outputs.azureFWPolicyId
  }
}

🔹 modules/hub.bicep — Hub + Firewall

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
param location string
param hubVnetName string
param hubAddressPrefix string
param firewallName string

resource hubVnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {
  name: hubVnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [hubAddressPrefix]
    }
    subnets: [
      {
        name: 'AzureFirewallSubnet'
        properties: { addressPrefix: '10.0.1.0/24' }
      }
      {
        name: 'GatewaySubnet'
        properties: { addressPrefix: '10.0.255.0/27' }
      }
      {
        name: 'SharedServices'
        properties: { addressPrefix: '10.0.2.0/24' }
      }
    ]
  }
}

resource publicIp 'Microsoft.Network/publicIPAddresses@2023-05-01' = {
  name: '${firewallName}-pip'
  location: location
  sku: {
    name: 'Standard'
    tier: 'Regional'
  }
  properties: {
    publicIPAllocationMethod: 'Static'
  }
}

resource firewall 'Microsoft.Network/azureFirewalls@2023-05-01' = {
  name: firewallName
  location: location
  properties: {
    sku: {
      name: 'AZFW_VNet'
      tier: 'Premium'
    }
    ipConfigurations: [
      {
        name: 'fwconfig'
        properties: {
          subnet: {
            id: hubVnet.properties.subnets[0].id
          }
          publicIPAddress: {
            id: publicIp.id
          }
        }
      }
    ]
  }
}

output hubVnetId string = hubVnet.id
output firewallPrivateIp string = firewall.properties.ipConfigurations[0].properties.privateIPAddress

🔹 modules/spoke.bicep — Spoke VNet + Subnet NSG + UDR

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
param location string
param spokeVnetName string
param spokeAddressPrefix string
param firewallPrivateIp string

resource spokeVnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {
  name: spokeVnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [spokeAddressPrefix]
    }
    subnets: [
      {
        name: 'workload'
        properties: {
          addressPrefix: '10.1.1.0/24'
        }
      }
    ]
  }
}

// Route Table
resource routeTable 'Microsoft.Network/routeTables@2023-05-01' = {
  name: '${spokeVnetName}-udr'
  location: location
  properties: {
    routes: [
      {
        name: 'default-to-firewall'
        properties: {
          addressPrefix: '0.0.0.0/0'
          nextHopType: 'VirtualAppliance'
          nextHopIpAddress: firewallPrivateIp
        }
      }
    ]
  }
}

// Update subnet with Route Table association
resource spokeSubnet 'Microsoft.Network/virtualNetworks/subnets@2023-05-01' = {
  name: '${spokeVnet.name}/workload'
  properties: {
    addressPrefix: '10.1.1.0/24'
    routeTable: {
      id: routeTable.id
    }
  }
  dependsOn: [spokeVnet]
}

// NSG with sample rules
resource spokeNsg 'Microsoft.Network/networkSecurityGroups@2023-05-01' = {
  name: '${spokeVnetName}-nsg'
  location: location
  properties: {
    securityRules: [
      {
        name: 'allow-https-out'
        properties: {
          priority: 100
          direction: 'Outbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '443'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'deny-all-in'
        properties: {
          priority: 200
          direction: 'Inbound'
          access: 'Deny'
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
        }
      }
    ]
  }
}

// Associate NSG
resource subnetNsgAssoc 'Microsoft.Network/virtualNetworks/subnets/networkSecurityGroups@2023-05-01' = {
  name: '${spokeVnet.name}/workload/${spokeNsg.name}'
  properties: {}
}

output spokeVnetId string = spokeVnet.id

🔹 modules/peering.bicep — Hub-Spoke Peering

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
param hubVnetName string
param spokeVnetName string

resource hubVnet 'Microsoft.Network/virtualNetworks@2023-05-01' existing = {
  name: hubVnetName
}

resource spokeVnet 'Microsoft.Network/virtualNetworks@2023-05-01' existing = {
  name: spokeVnetName
}

resource hubToSpoke 'Microsoft.Network/virtualNetworks/virtualNetworkPeerings@2023-05-01' = {
  name: 'hub-to-spoke'
  parent: hubVnet
  properties: {
    remoteVirtualNetwork: {
      id: spokeVnet.id
    }
    allowVirtualNetworkAccess: true
    allowForwardedTraffic: true
    allowGatewayTransit: true
  }
}

resource spokeToHub 'Microsoft.Network/virtualNetworks/virtualNetworkPeerings@2023-05-01' = {
  name: 'spoke-to-hub'
  parent: spokeVnet
  properties: {
    remoteVirtualNetwork: {
      id: hubVnet.id
    }
    allowVirtualNetworkAccess: true
    allowForwardedTraffic: true
    useRemoteGateways: true
  }
}

🔹 modules/azfwpolicy.bicep — Azure Firewall Policy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@description('Name of the Azure Firewall Policy')
param firewallpolicyName string = 'myAzureFirewallPolicy'

@description('Location for all resources')
param location string = resourceGroup().location

@description('Azure Firewall Policy')
resource azureFWPolicy 'Microsoft.Network/firewallPolicies@2024-05-01' = {
  name: firewallpolicyName
  location: location
}

resource networkRuleCollectionGroup 'Microsoft.Network/firewallPolicies/ruleCollectionGroups@2022-01-01' = {
  parent: azureFWPolicy
  name: 'DefaultNetworkRuleCollectionGroup'
  properties: {
    priority: 200
    ruleCollections: [
      {
        ruleCollectionType: 'FirewallPolicyFilterRuleCollection'
        action: {
          type: 'Allow'
        }
        name: 'AllowAllExceptHTTPandHTTPS'
        priority: 200
          rules: [
            {
              name: 'AllowAllExcept80and443'
              description: 'Allow all traffic except ports 80 and 443'
              ruleType: 'NetworkRule'
              ipProtocols: [
                'Any'
              ]
              sourceAddresses: [
                '*'
              ]
              destinationAddresses: [
                '*'
              ]
              destinationPorts: [
                '1-79'
                '81-442'
                '444-60000'
              ]
            }
          ]
        }
    ]
  }
}

resource applicationRuleCollectionGroup 'Microsoft.Network/firewallPolicies/ruleCollectionGroups@2022-01-01' = {
  parent: azureFWPolicy
  name: 'DefaultApplicationRuleCollectionGroup'
  properties: {
    priority: 300
    ruleCollections: [
      {
        ruleCollectionType: 'FirewallPolicyFilterRuleCollection'
        name: 'AllowHTTPandHTTPS'
        priority: 1000
        action: {
          type: 'Allow'
        }
        rules: [
          {
            ruleType: 'ApplicationRule'
            name: 'AllowHTTPandHTTPS'
              protocols: [
                {
                  protocolType: 'Http'
                  port: 80
                }
                {
                  protocolType: 'Https'
                  port: 443
                }
              ]
              sourceAddresses: [
                '*'
              ]
              targetFqdns: [
                '*'
              ]
            }
          ]
        }
    ]
  }
}

output azureFWPolicyId string = azureFWPolicy.id

🔹 modules/azfw.bicep — Azure Firewall

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
param firewallName string
param location string
param vnetName string
param firewallPolicyId string

@description('Public IP Name for Azure Firewall')
param firewallPublicIPName string = '${firewallName}-publicIP'

@description('Public IP for Azure Firewall')
resource firewallPublicIP 'Microsoft.Network/publicIPAddresses@2023-05-01' = {
  name: firewallPublicIPName
  location: location
  sku: {
    name: 'Standard'
    tier: 'Regional'
  }
  properties: {
    publicIPAllocationMethod: 'Static'
  }
}

@description('Azure Firewall Resource')
resource azureFirewall 'Microsoft.Network/azureFirewalls@2023-05-01' = {
  name: firewallName
  location: location
  properties: {
        ipConfigurations: [
      {
        name: '${firewallName}-ipConfig'
        properties: {
          publicIPAddress: {
            id: firewallPublicIP.id
          }
          subnet: {
            id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnetName, 'AzureFirewallSubnet')
          }
        }
      }
    ]
    firewallPolicy: {
      id: firewallPolicyId
    }
  }
}
🍺
Brewed Insight: By modularising, you’re essentially setting up a reusable “coffee recipe”. Add more spokes? Just call the spoke.bicep module with a new name and CIDR. Want multi-region? Deploy another hub.bicep and connect. Keeps deployments consistent, scalable, and way less error‑prone.

Gotchas & Edge Cases

  • Peering limits: Default hard caps exist; plan address ranges carefully.
  • DNS resolution: Centralise DNS in the hub to avoid mismatched name resolution.
  • Firewall costs: Premium SKU punches holes in budgets if underutilised. Scale and size properly.

Best Practices

  • Go cloud native first by using Azure Firewall (premium where inspection required) for hub security over third-party NVAs unless you have a feature Azure doesn’t support.
  • Enable Logging + Diagnostics from day one. Push Firewall, NSG, and Azure Activity logs into Log Analytics / Sentinel.
  • Align NSGs with segmentation — firewalls manage inter-VNet traffic, NSGs should refine subnet/application boundaries.
  • Plan IP ranges early to avoid re-addressing (think coffee stains on a white shirt — harder to clean up later!).
🍺
Brewed Insight: Hub-and-spoke in Azure is a classic design that’s aged surprisingly well. The trick isn’t in building it — it’s in securing it properly. Think of it like a strong flat white: the foundation is simple (espresso + milk), but small variations in execution make all the difference. In the next post, we’ll pour a deeper shot and focus on how Azure Firewall Premium becomes the real enforcer of security.

Learn More