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)
- Create Hub VNet with subnets:
AzureFirewallSubnet
, GatewaySubnet
, SharedServices
.
- Deploy Azure Firewall into
AzureFirewallSubnet
.
- Create Spoke VNets for workloads.
- Peer each Spoke VNet with the Hub VNet (enable “Use remote gateway” for spoke).
- Assign UDRs in spokes to route 0.0.0.0/0 to the firewall’s private IP.
- 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.
📂 Folder Structure (recommended)
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