Azure Landing Zone Networking: Hub-Spoke with Firewall
You have your management groups sorted out, subscriptions assigned, and a clean Bicep module structure after Part 1. Now comes the part that breaks people: networking. Specifically, the moment you realize your fifth team just deployed their own Azure Firewall because nobody set up a shared hub, and you are staring at a $3,200/month bill that should have been $850.
Effort: 3-5 days for full hub-spoke deployment including firewall rules Cost: ~$850/month (Azure Firewall Basic: $650, VPN Gateway: $140, Private DNS: $25, peering: ~$35) Prerequisites: Completed Landing Zone foundation (Part 1), Azure subscription with Contributor on connectivity subscription, VS Code with Bicep extension
What Changed in 2025
Three things shifted the hub-spoke playbook significantly this year.
Azure Firewall Basic SKU went GA. Before this, the cheapest option was Azure Firewall Standard at roughly $912/month. The Basic SKU lands at around $650/month with the same core L3/L4 filtering -- it just caps throughput at 250 Mbps and drops IDPS signatures. For Landing Zones with fewer than 20 spokes and no inline threat intelligence requirement, Basic is the right call.
Azure Virtual Network Manager hit general availability. This replaces the old manual-peering-with-Bicep-loops approach. You define connectivity configurations (hub-spoke or mesh) declaratively, and AVNM handles peering lifecycle. It even supports dynamic group membership based on tags, which means new spoke VNets get peered automatically.
Private DNS Resolver replaced conditional forwarders. The legacy pattern of running BIND or Windows DNS VMs in the hub for on-premises resolution is dead. Azure DNS Private Resolver gives you inbound and outbound endpoints in the hub VNet at a fraction of the operational overhead.
Why This Matters
Without a shared hub, each spoke team solves their own networking problems independently. I have seen this pattern play out at a financial services client with seven application teams:
| Approach | Monthly Cost | Operational Overhead |
|---|---|---|
| Shared hub-spoke (1 firewall) | ~$850 | Centralized team manages rules |
| Per-spoke firewalls (7 teams) | ~$6,400 | Each team manages own rules |
| No firewall (direct internet) | ~$50 | Zero visibility, audit failure |
The per-spoke approach also means seven different firewall rule sets, seven different logging configurations, and seven different teams who will all configure DNS differently. When your compliance auditor asks "show me all egress traffic for the last 90 days," you are pulling logs from seven places.
The hub-spoke model centralizes this: one firewall, one DNS configuration, one set of route tables, one place to look when something breaks.
Implementation: Hub-Spoke with Bicep Modules
The architecture breaks into four Bicep modules: the hub VNet, spoke VNet(s), Azure Firewall with policy, and Private DNS zones. Here is the hub network module.
Hub VNet Module
// modules/network/hub-vnet.bicep
// Deploy the hub virtual network with required subnets
@description('Azure region for hub deployment')
param location string = 'westeurope'
@description('Environment identifier')
param environmentType string = 'prod'
@description('Hub VNet address space')
param hubAddressPrefix string = '10.0.0.0/16'
resource hubVnet 'Microsoft.Network/virtualNetworks@2023-11-01' = {
name: 'vnet-hub-${location}-001'
location: location
properties: {
addressSpace: {
addressPrefixes: [hubAddressPrefix]
}
subnets: [
{
name: 'AzureFirewallSubnet'
properties: {
addressPrefix: '10.0.1.0/26'
}
}
{
name: 'GatewaySubnet'
properties: {
addressPrefix: '10.0.2.0/27'
}
}
{
name: 'AzureBastionSubnet'
properties: {
addressPrefix: '10.0.3.0/26'
}
}
{
name: 'snet-dns-inbound-${location}'
properties: {
addressPrefix: '10.0.4.0/28'
delegations: [
{
name: 'Microsoft.Network.dnsResolvers'
properties: {
serviceName: 'Microsoft.Network/dnsResolvers'
}
}
]
}
}
]
}
}
output hubVnetId string = hubVnet.id
output hubVnetName string = hubVnet.name
output firewallSubnetId string = hubVnet.properties.subnets[0].idThe AzureFirewallSubnet name is mandatory -- Azure will reject any other name for the firewall's subnet. Same rule for GatewaySubnet. I wasted half a day on my first Landing Zone deployment because I named the subnet snet-firewall and got a cryptic deployment error.
Spoke VNet Module with Peering
// modules/network/spoke-vnet.bicep
// Deploy a spoke VNet and configure bidirectional peering with the hub
@description('Spoke identifier, e.g. app01, data01')
param spokeName string
@description('Hub VNet resource ID for peering')
param hubVnetId string
@description('Hub VNet name for peering reference')
param hubVnetName string
param location string = 'westeurope'
param spokeAddressPrefix string
resource spokeVnet 'Microsoft.Network/virtualNetworks@2023-11-01' = {
name: 'vnet-spoke-${spokeName}-${location}-001'
location: location
properties: {
addressSpace: {
addressPrefixes: [spokeAddressPrefix]
}
subnets: [
{
name: 'snet-default-${spokeName}'
properties: {
addressPrefix: spokeAddressPrefix
routeTable: {
id: spokeRouteTable.id
}
}
}
]
}
}
// Route all traffic through the hub firewall
resource spokeRouteTable 'Microsoft.Network/routeTables@2023-11-01' = {
name: 'rt-spoke-${spokeName}-${location}'
location: location
properties: {
disableBgpRoutePropagation: true
routes: [
{
name: 'route-to-firewall'
properties: {
addressPrefix: '0.0.0.0/0'
nextHopType: 'VirtualAppliance'
nextHopIpAddress: '10.0.1.4' // Azure Firewall private IP
}
}
]
}
}
// Spoke -> Hub peering
resource spokeToHub 'Microsoft.Network/virtualNetworks/virtualNetworkPeerings@2023-11-01' = {
parent: spokeVnet
name: 'peer-${spokeName}-to-hub'
properties: {
remoteVirtualNetwork: {
id: hubVnetId
}
allowForwardedTraffic: true
allowGatewayTransit: false
useRemoteGateways: true
}
}
output spokeVnetId string = spokeVnet.idThat disableBgpRoutePropagation: true on the route table is one you will forget exactly once. Without it, BGP routes from the VPN Gateway override your user-defined routes, and traffic bypasses the firewall entirely. The firewall logs will show zero traffic while everything works perfectly -- until the security audit.
Azure Firewall with Policy
// modules/network/firewall.bicep
// Deploy Azure Firewall Basic with application and network rule collections
param location string = 'westeurope'
param firewallSubnetId string
param skuTier string = 'Basic'
resource fwPublicIp 'Microsoft.Network/publicIPAddresses@2023-11-01' = {
name: 'pip-fw-hub-${location}-001'
location: location
sku: {
name: 'Standard'
}
properties: {
publicIPAllocationMethod: 'Static'
}
}
resource fwPolicy 'Microsoft.Network/firewallPolicies@2023-11-01' = {
name: 'fwpol-hub-${location}-001'
location: location
properties: {
sku: {
tier: skuTier
}
threatIntelMode: 'Alert'
}
}
resource networkRuleCollection 'Microsoft.Network/firewallPolicies/ruleCollectionGroups@2023-11-01' = {
parent: fwPolicy
name: 'rcg-network-baseline'
properties: {
priority: 200
ruleCollections: [
{
ruleCollectionType: 'FirewallPolicyFilterRuleCollection'
name: 'rc-allow-spoke-to-spoke'
priority: 210
action: {
type: 'Allow'
}
rules: [
{
ruleType: 'NetworkRule'
name: 'allow-rfc1918'
sourceAddresses: ['10.0.0.0/8']
destinationAddresses: ['10.0.0.0/8']
destinationPorts: ['*']
ipProtocols: ['TCP', 'UDP']
}
]
}
]
}
}
resource appRuleCollection 'Microsoft.Network/firewallPolicies/ruleCollectionGroups@2023-11-01' = {
parent: fwPolicy
name: 'rcg-application-baseline'
dependsOn: [networkRuleCollection]
properties: {
priority: 300
ruleCollections: [
{
ruleCollectionType: 'FirewallPolicyFilterRuleCollection'
name: 'rc-allow-azure-management'
priority: 310
action: {
type: 'Allow'
}
rules: [
{
ruleType: 'ApplicationRule'
name: 'allow-azure-management'
sourceAddresses: ['10.0.0.0/8']
protocols: [
{
protocolType: 'Https'
port: 443
}
]
targetFqdns: [
'management.azure.com'
'login.microsoftonline.com'
'*.blob.core.windows.net'
]
}
]
}
]
}
}
resource firewall 'Microsoft.Network/azureFirewalls@2023-11-01' = {
name: 'fw-hub-${location}-001'
location: location
properties: {
sku: {
name: 'AZFW_VNet'
tier: skuTier
}
firewallPolicy: {
id: fwPolicy.id
}
ipConfigurations: [
{
name: 'fw-ipconfig'
properties: {
subnet: {
id: firewallSubnetId
}
publicIPAddress: {
id: fwPublicIp.id
}
}
}
]
}
}
output firewallPrivateIp string = firewall.properties.ipConfigurations[0].properties.privateIPAddressNote the dependsOn: [networkRuleCollection] on the application rule collection group. Firewall policy rule collection groups deploy in parallel by default, and Azure will throw a conflict error if two collection groups try to write simultaneously. This is one of those issues that never shows up in small test deployments but fails consistently in CI/CD pipelines.
Real-World Results
After deploying this hub-spoke topology for a mid-size SaaS company running four spoke VNets (app, data, shared services, staging), here is what the firewall resource looks like:
$ az network firewall show \
--name fw-hub-westeurope-001 \
--resource-group rg-connectivity-prod \
--query '{name:name, sku:sku.tier, provisioningState:provisioningState, privateIp:ipConfigurations[0].privateIPAddress, threatIntelMode:threatIntelMode}' \
--output table
Name Sku ProvisioningState PrivateIp ThreatIntelMode
------------------------ ----- ------------------- ----------- -----------------
fw-hub-westeurope-001 Basic Succeeded 10.0.1.4 Alert
The monthly bill for the full hub networking stack settled at:
| Resource | Monthly Cost |
|---|---|
| Azure Firewall Basic | $648.00 |
| VPN Gateway (VpnGw1) | $138.70 |
| Private DNS Zones (4 zones) | $24.00 |
| VNet Peering (4 spokes, ~500GB) | $35.00 |
| Public IP (Standard, static) | $3.65 |
| Total | $849.35 |
One thing that caught us off guard: the SubnetNotInSameVnet error during peering. We had a Bicep deployment that tried to create the peering before the spoke VNet's subnet was fully provisioned. The fix was adding an explicit dependsOn to the peering resource referencing the spoke VNet -- the implicit dependency from parent: spokeVnet was not enough because the subnets are inline resources.
Key Takeaways
- Start with Azure Firewall Basic unless you need IDPS or TLS inspection. You can upgrade the SKU later without redeploying.
- Always disable BGP route propagation on spoke route tables. Without it, Gateway-learned routes override your UDR and traffic bypasses the firewall silently.
- Use Private DNS Resolver instead of DNS VMs. The operational overhead of maintaining BIND or Windows DNS forwarders in a hub VNet is not worth it anymore.
- Add
dependsOnbetween firewall policy rule collection groups. Parallel deployment of collection groups causes intermittent ARM conflicts that are painful to debug. - Budget for the hub as a shared platform cost, not charged to individual application teams. The moment you start splitting firewall costs per spoke, teams will find ways to avoid the hub entirely.
If your Landing Zone foundation from Part 1 is running cleanly, hub-spoke networking is the logical next step. Once egress traffic flows through a central firewall, you have the visibility and control you need for the governance layer -- which is exactly what we tackle in Part 3 of this series. For organizations looking to accelerate their cloud infrastructure journey, our cloud architecture consulting covers the full Landing Zone deployment lifecycle.
About the author

Martin Rylko
Senior Cloud Architect & DevOps Engineer
14+ years in IT – from on-premises datacenters and Hyper-V clustering to cloud infrastructure on Microsoft Azure. I specialize in Landing Zones, IaC automation, Kubernetes and security compliance.
Frequently Asked Questions
How much does Azure Firewall cost in a hub-spoke Landing Zone?▾
When should I use hub-spoke vs Azure Virtual WAN?▾
Are there limits on VNet peering in Azure?▾
How do I handle DNS resolution across spokes in a hub-spoke topology?▾
You might also like
Azure Landing Zone with Bicep: Enterprise Setup
Deploy an enterprise-ready Azure Landing Zone using Bicep modules. Covers hub-spoke networking, policy governance, and CI/CD pipeline integration.
ReadAzure Landing Zone Governance: Policy at Scale
Implement Azure Policy governance for Landing Zones at scale. Custom policy definitions, initiative assignments, compliance dashboards, and cost management guardrails.
ReadBicep CI/CD: GitHub Actions Pipeline for Azure
Build a production Bicep deployment pipeline with GitHub Actions. Covers what-if previews, environment approvals, OIDC authentication, and rollback strategies.
Read