10

Using Azure Firewall as a NVA with Terraform

Using Azure Firewall as a Network Virtual Appliance (NVA) provides a powerful alternative to traditional VNet peering in hub-and-spoke designs.…

Using Azure Firewall as a Network Virtual Appliance (NVA) provides a powerful alternative to traditional VNet peering in hub-and-spoke designs. Instead of relying on implicit trust between spokes, all inter-VNet and outbound traffic can be routed through a central inspection point where rules, logging, and governance are enforced.

This post builds on the FastTrack for Azure article Using Azure Firewall as a network virtual appliance (NVA) and reproduces a similar architecture using Terraform. The goal is to demonstrate how the entire setup can be deployed as Infrastructure-as-Code, including optional integration with Azure Network Manager.

– Big thanks to DJBartles for that great blog article! Feel free to check his blog for more information.

Why Use Azure Firewall as an NVA Instead of Native Peering?

Native VNet peering creates a high-trust path between networks. This is simple and fast, but it offers little visibility or control. By placing a firewall in the hub and routing traffic through it, you gain:

  • Central policy enforcement across all spokes
  • Full logging and visibility via Azure Monitor
  • Consistent traffic inspection for both east-west and outbound flows
  • Better governance for enterprise networks
  • Scalability when combined with Azure Network Manager

This design is especially helpful when more spokes or teams join the environment over time.

Terraform Deployment Overview

The Terraform code for this blog post is divided into several logical sections. Instead of exposing one large file, we walk through the important parts step by step — firewall, policies, VNets, routing, and the optional Network Manager approach.

See the full code on Github


1. Basic Components: Firewall, Policy, and Rules

We start with the essentials needed to run Azure Firewall:

Firewall, Firewall Policy & Rule Collection (Snippet)

# ============================================================================
# Firewall
# ============================================================================
# Public IP für Firewall
resource "azurerm_public_ip" "firewall" {
  name                = "pip-firewall"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# Firewall Policy
resource "azurerm_firewall_policy" "policy" {
  name                = "fw-policy"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  sku                 = "Standard"
}

resource "azurerm_firewall_policy_rule_collection_group" "network_rules" {
  name               = "network-rules"
  firewall_policy_id = azurerm_firewall_policy.policy.id
  priority           = 100

  network_rule_collection {
    name     = "allow-spoke-to-spoke"
    priority = 100
    action   = "Allow"
    
    ###
    #Required Rules - these can be seen as the peering between the spoke vnets
    ###
    rule {
      name                  = "spoke1-to-spoke2"
      protocols             = ["Any"]
      source_addresses      = ["10.1.0.0/16"]
      destination_addresses = ["10.2.0.0/16"]
      destination_ports     = ["*"]
    }

    rule {
      name                  = "spoke2-to-spoke1"
      protocols             = ["Any"]
      source_addresses      = ["10.2.0.0/16"]
      destination_addresses = ["10.1.0.0/16"]
      destination_ports     = ["*"]
    }
  }
}

# Azure Firewall
resource "azurerm_firewall" "fw" {
  name                = "fw-hub"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  sku_name            = "AZFW_VNet"
  sku_tier            = "Standard"
  firewall_policy_id  = azurerm_firewall_policy.policy.id

  ip_configuration {
    name                 = "configuration"
    subnet_id            = azurerm_subnet.firewall.id
    public_ip_address_id = azurerm_public_ip.firewall.id
  }
}

2. VNets and Hub–Spoke Peerings

We deploy one hub VNet and two spokes.

Each spoke contains a workload subnet, and both spokes are peered to the hub.

VNet & Peering Snippets

# ============================================================================
# Vnets & Subnets
# ============================================================================
# Hub VNet
resource "azurerm_virtual_network" "hub" {
  name                = "vnet-hub"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "firewall" {
  name                 = "AzureFirewallSubnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = ["10.0.1.0/26"]
}

# Spoke 1 VNet
resource "azurerm_virtual_network" "spoke1" {
  name                = "vnet-spoke1"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  address_space       = ["10.1.0.0/16"]
}

resource "azurerm_subnet" "spoke1_workload" {
  name                 = "snet-workload"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.spoke1.name
  address_prefixes     = ["10.1.1.0/24"]
}

# Spoke 2 VNet
resource "azurerm_virtual_network" "spoke2" {
  name                = "vnet-spoke2"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  address_space       = ["10.2.0.0/16"]
}

resource "azurerm_subnet" "spoke2_workload" {
  name                 = "snet-workload"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.spoke2.name
  address_prefixes     = ["10.2.1.0/24"]
}

# ============================================================================
# Peerings to Hub
# ============================================================================
#Peerings
resource "azurerm_virtual_network_peering" "hub_to_spoke1" {
  name                         = "peer-hub-to-spoke1"
  resource_group_name          = azurerm_resource_group.rg.name
  virtual_network_name         = azurerm_virtual_network.hub.name
  remote_virtual_network_id    = azurerm_virtual_network.spoke1.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  use_remote_gateways          = false
}

resource "azurerm_virtual_network_peering" "spoke1_to_hub" {
  name                         = "peer-spoke1-to-hub"
  resource_group_name          = azurerm_resource_group.rg.name
  virtual_network_name         = azurerm_virtual_network.spoke1.name
  remote_virtual_network_id    = azurerm_virtual_network.hub.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  use_remote_gateways          = false
}

resource "azurerm_virtual_network_peering" "hub_to_spoke2" {
  name                         = "peer-hub-to-spoke2"
  resource_group_name          = azurerm_resource_group.rg.name
  virtual_network_name         = azurerm_virtual_network.hub.name
  remote_virtual_network_id    = azurerm_virtual_network.spoke2.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  use_remote_gateways          = false
}

resource "azurerm_virtual_network_peering" "spoke2_to_hub" {
  name                         = "peer-spoke2-to-hub"
  resource_group_name          = azurerm_resource_group.rg.name
  virtual_network_name         = azurerm_virtual_network.spoke2.name
  remote_virtual_network_id    = azurerm_virtual_network.hub.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  use_remote_gateways          = false
}

Important: allowForwardedTraffic = true is required so the spokes accept traffic being forwarded through the firewall.

3. Routing Through the Firewall

To use Azure Firewall as an NVA, traffic must be redirected through its private IP.

This post shows two variants:


Variant 1: Direct UDR Assignment

This is the simplest approach — a default route points to the firewall.

UDR Snippet & Associations to the spoke subnets

# ============================================================================
# Deploy UDR directly
# ============================================================================
resource "azurerm_route_table" "udr_to_fw" {
  name                = "rt-route-to-fw"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  route = [
    {
      name                   = "fwroute"
      address_prefix         = "0.0.0.0/0"
      next_hop_type          = "VirtualAppliance"
      next_hop_in_ip_address = azurerm_firewall.fw.ip_configuration[0].private_ip_address
    }
  ]
}

resource "azurerm_subnet_route_table_association" "spoke1_rt" {
  route_table_id = azurerm_route_table.udr_to_fw.id
  subnet_id      = azurerm_subnet.spoke1_workload.id
}

resource "azurerm_subnet_route_table_association" "spoke2_rt" {
  route_table_id = azurerm_route_table.udr_to_fw.id
  subnet_id      = azurerm_subnet.spoke2_workload.id
}

This method is easy to manage in small environments but becomes harder to scale when new spokes appear.

Variant 2: Routing via Azure Network Manager

Azure Network Manager (ANM) brings centralized, policy-driven routing.

It is highly recommended in larger or frequently changing environments.

Why Azure Network Manager?

  • Centralized routing governance
  • Automatic configuration rollout
  • Dynamic grouping of VNets
  • Cleaner separation of responsibilities (Connectivity, Security, Routing)

Network Manager & Routing Snippet

# ============================================================================
# Deploy UDR via Network Manager
# ============================================================================
# Network Manager
resource "azurerm_network_manager" "nm" {
  name                = "nm-hub-spoke"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  scope {
    subscription_ids = [data.azurerm_subscription.current.id]
  }
  scope_accesses = ["Connectivity", "SecurityAdmin", "Routing"]
}

# Network Group für Spokes
resource "azurerm_network_manager_network_group" "spokes" {
  name               = "ng-spokes"
  network_manager_id = azurerm_network_manager.nm.id
}

# Routing Configuration - User Defined Routes zu Firewall
resource "azurerm_network_manager_routing_configuration" "routing" {
  name               = "routing-to-firewall"
  network_manager_id = azurerm_network_manager.nm.id
}

resource "azurerm_network_manager_routing_rule_collection" "spoke_routes" {
  name                     = "spoke-to-firewall-routes"
  routing_configuration_id = azurerm_network_manager_routing_configuration.routing.id
  network_group_ids        = [azurerm_network_manager_network_group.spokes.id]
}

resource "azurerm_network_manager_routing_rule" "routing_rule" {
  name               = "default_to_fwnva"
  rule_collection_id = azurerm_network_manager_routing_rule_collection.spoke_routes.id
  description        = "Default Route to Azure Firewall"

  destination {
    type    = "AddressPrefix"
    address = "0.0.0.0/0"
  }

  next_hop {
    address = "10.0.1.4" #azurerm_firewall.fw.ip_configuration[0].private_ip_address
    type    = "VirtualAppliance"
  }
}

# Deployment der Routing Configuration
resource "azurerm_network_manager_deployment" "routing_deployment" {
  network_manager_id = azurerm_network_manager.nm.id
  location           = azurerm_resource_group.rg.location
  scope_access       = "Routing"
  configuration_ids  = [azurerm_network_manager_routing_configuration.routing.id]
  depends_on         = [azurerm_network_manager.nm, azurerm_network_manager_routing_rule.routing_rule]
}

How to add Vnets to the Network Group?

Static Network Group Membership – manually
resource "azurerm_network_manager_static_member" "spoke1" {
  name                      = "spoke1-member"
  network_group_id          = azurerm_network_manager_network_group.spokes.id
  target_virtual_network_id = azurerm_virtual_network.spoke1.id
}

resource "azurerm_network_manager_static_member" "spoke2" {
  name                      = "spoke2-member"
  network_group_id          = azurerm_network_manager_network_group.spokes.id
  target_virtual_network_id = azurerm_virtual_network.spoke2.id
}

Good for controlled environments, but requires manual updates.

Dynamic Membership via Azure Policy
resource "azurerm_policy_definition" "vnet_to_network_group" {
  name         = "add-vnet-to-network-group"
  policy_type  = "Custom"
  mode         = "Microsoft.Network.Data"
  display_name = "Add VNets with specific name to Network Group"

  metadata = jsonencode({
    category = "Network"
  })

  policy_rule = jsonencode({
    if = {
      allOf = [
        {
          field  = "type"
          equals = "Microsoft.Network/virtualNetworks"
        },
        {
          field = "name"
          contains  = "spoke"
        }
      ]
    }
    then = {
      effect = "addToNetworkGroup"
      details = {
        networkGroupId = azurerm_network_manager_network_group.spokes.id
      }
    }
  })
}

resource "azurerm_subscription_policy_assignment" "nwm-group-assignment" {
  name                 = "assign-vnet-policy"
  policy_definition_id = azurerm_policy_definition.vnet_to_network_group.id
  subscription_id      = data.azurerm_subscription.current.id
}

Why policies for membership?

  • Zero operational effort
  • Perfect for large or automated deployments
  • Ensures consistent routing behavior across environments
  • Spokes are added automatically if their name contains “spoke” – or how you want

Summary

In this post, we deployed a complete hub-and-spoke network with Azure Firewall acting as a central NVA. You saw how to:

  • Build the hub, spokes, firewall, and routing configuration
  • Route traffic through Azure Firewall via UDR or Network Manager
  • Use Azure Policy to automate network group membership

The Network Manager approach is especially useful for enterprises that need scalable, centrally governed routing.

Which approach do you use? Direct peering or a centralized Firewall for more control?

Simon

Cloud Engineer focused on Azure, Terraform & PowerShell. Passionate about automation, efficient solutions, and sharing real-world cloud projects and insights.⸻Let me know if you’d like to make it more casual, technical, or personalized.

Leave a Reply

Your email address will not be published. Required fields are marked *