Martin Rylko
  • Services
  • Blog
  • About
  • Contact
  • Get in Touch
Martin Rylko

Senior Cloud Architect & DevOps Engineer. Specializing in Microsoft Azure, IaC, Cloud Security and AI.

Navigation

  • Services
  • Blog
  • About
  • Contact

Collaboration

Looking for an experienced architect for your Azure project? Get in touch.

rylko@cloudmasters.cz

© 2026 Martin Rylko. All rights reserved.

Built in the cloud. Deployed via Azure Static Web Apps.

Home/Blog/Azure Landing Zone Networking: Hub-Spoke with Firewall
All articlesČíst česky

Azure Landing Zone Networking: Hub-Spoke with Firewall

4/15/2025 5 min
#Azure#Landing Zone#Networking#Hub-Spoke#Bicep

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:

ApproachMonthly CostOperational Overhead
Shared hub-spoke (1 firewall)~$850Centralized team manages rules
Per-spoke firewalls (7 teams)~$6,400Each team manages own rules
No firewall (direct internet)~$50Zero 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].id

The 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.id

That 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.privateIPAddress

Note 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:

ResourceMonthly 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 dependsOn between 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.

Tags:#Azure#Landing Zone#Networking#Hub-Spoke#Bicep
LinkedInX / Twitter

About the author

Martin Rylko

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.

Email LinkedInFull profile

Frequently Asked Questions

How much does Azure Firewall cost in a hub-spoke Landing Zone?▾
Azure Firewall Basic SKU starts at approximately $282/month (fixed deployment cost) plus data processing charges. The Standard SKU runs about $912/month. For most initial Landing Zone deployments, I recommend starting with Basic SKU and upgrading to Standard when you need IDPS or TLS inspection. Premium adds another ~$500/month for enterprise-grade features.
When should I use hub-spoke vs Azure Virtual WAN?▾
Hub-spoke with a self-managed hub VNet gives you full control over routing, firewall rules, and DNS -- ideal when you need granular network policy and have the team to manage it. Virtual WAN is better when you have 10+ branch offices or need automated site-to-site VPN mesh. For most Landing Zone projects with 2-5 spoke subscriptions, classic hub-spoke is simpler and cheaper.
Are there limits on VNet peering in Azure?▾
Each VNet supports up to 500 peering connections, which is more than enough for most Landing Zones. The real constraint is address space planning -- overlapping CIDR ranges between peered VNets will fail. I recommend planning a /16 for the hub and /20 per spoke, giving you room for 4,096 hosts per spoke and 65,536 addresses in the hub.
How do I handle DNS resolution across spokes in a hub-spoke topology?▾
Deploy Azure Private DNS Zones linked to the hub VNet and configure spoke VNets to use the hub as their DNS server (or use Azure Firewall DNS proxy). This centralizes DNS resolution for Private Endpoints and custom domains. Each spoke should forward DNS queries to the hub rather than maintaining its own DNS infrastructure.

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.

Read

Azure 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.

Read

Bicep 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