top of page
  • 1 day ago
  • 28 min read

Azure Firewall Deep Dive

Centralize Egress, DNAT, Network Rules, Application Rules, and DNS Proxy


Azure Firewall provides centralized, stateful traffic inspection for Azure networks. In a hub-and-spoke design, workload subnets can use user-defined routes to send outbound and east-west traffic through a firewall in the hub before the traffic reaches its destination.

In this workshop, you will build a secured hub-and-spoke environment using PowerShell 7 and Azure CLI.

You will configure:

  • centralized Internet egress;

  • spoke-to-spoke traffic inspection;

  • network rules;

  • application rules;

  • inbound DNAT;

  • DNS proxy;

  • default-deny behavior;

  • rule-processing tests;

  • structured diagnostic logs;

  • allowed and blocked connectivity tests.

Microsoft recommends a hub-and-spoke model for production firewall deployments, with Azure Firewall placed in a dedicated AzureFirewallSubnet. The subnet should be at least /26.

Cost warningAzure Firewall is a billable service. The workshop also creates public IP addresses, a Log Analytics workspace, and two virtual machines.Delete the resource group when the workshop is complete.

Workshop overview

Item

Configuration

Level

Intermediate to advanced

Estimated duration

75–120 minutes

Deployment interface

PowerShell 7 and Azure CLI

Azure region

West Europe

Firewall SKU

Standard

Architecture

Hub-and-spoke

Spoke VNets

Two

Workload VMs

Client VM and web VM

Outbound control

Application rules

East-west control

Network rules

Inbound publishing

DNAT

DNS

Azure Firewall DNS proxy

Logging

Log Analytics resource-specific tables

What you will learn

By completing this workshop, you will learn how to:

  1. Build a secured Azure hub-and-spoke network.

  2. Deploy Azure Firewall into AzureFirewallSubnet.

  3. Peer workload VNets with a central hub.

  4. Route spoke traffic through Azure Firewall.

  5. Configure centralized Internet egress.

  6. Create application rules for approved FQDNs.

  7. Create network rules for private IP traffic.

  8. Configure inbound DNAT.

  9. Enable Azure Firewall DNS proxy.

  10. Configure spoke VNets to use the firewall for DNS.

  11. Validate the default-deny security model.

  12. Understand Azure Firewall rule-processing order.

  13. Demonstrate how a broad network rule can bypass application filtering.

  14. Enable structured diagnostic logs.

  15. Query firewall decisions with KQL.

  16. Troubleshoot routing, DNS, DNAT, and rule problems.

  17. Delete the complete lab safely.

Architecture

                                 Internet
                                    │
                 ┌──────────────────┴───────────────────┐
                 │                                      │
        Approved outbound HTTPS                  Inbound HTTP
        www.microsoft.com                       Firewall public IP
        api.ipify.org                                  Port 80
                 │                                      │
                 │ Application rules                    │ DNAT
                 │                                      │
        ┌────────▼──────────────────────────────────────▼────────┐
        │                  Hub VNet                              │
        │                  10.0.0.0/16                           │
        │                                                        │
        │  AzureFirewallSubnet: 10.0.0.0/26                     │
        │                                                        │
        │              ┌─────────────────────┐                   │
        │              │ Azure Firewall      │                   │
        │              │ Standard SKU        │                   │
        │              │                     │                   │
        │              │ DNS proxy           │                   │
        │              │ Network rules       │                   │
        │              │ Application rules   │                   │
        │              │ DNAT rules          │                   │
        │              └──────────┬──────────┘                   │
        └─────────────────────────┼──────────────────────────────┘
                                  │
                         Hub VNet peerings
                    ┌─────────────┴─────────────┐
                    │                           │
        ┌───────────▼────────────┐  ┌───────────▼────────────┐
        │ Client spoke          │  │ Web spoke             │
        │ 10.10.0.0/16          │  │ 10.20.0.0/16          │
        │                       │  │                       │
        │ Client VM             │  │ Web VM                │
        │ 10.10.1.4             │  │ 10.20.1.4             │
        │                       │  │                       │
        │ UDR:                  │  │ UDR:                  │
        │ 0.0.0.0/0 → Firewall  │  │ 0.0.0.0/0 → Firewall │
        └───────────────────────┘  └───────────────────────┘

Address plan

Component

Address range

Hub VNet

10.0.0.0/16

Azure Firewall subnet

10.0.0.0/26

Client spoke VNet

10.10.0.0/16

Client subnet

10.10.1.0/24

Client VM

10.10.1.4

Web spoke VNet

10.20.0.0/16

Web subnet

10.20.1.0/24

Web VM

10.20.1.4

The address spaces must not overlap.

Traffic flows

Centralized egress

Client VM
    │
    │ 0.0.0.0/0 UDR
    ▼
Azure Firewall
    │
    │ Application rule
    ▼
Approved Internet FQDN

Only the following HTTPS destinations are initially allowed:

www.microsoft.com
api.ipify.org

Other websites are denied by default.

East-west traffic

Client VM
10.10.1.4
    │
    │ UDR
    ▼
Azure Firewall
    │
    │ Network rule
    ▼
Web VM
10.20.1.4:8080

The firewall allows TCP port 8080.

TCP port 22 remains blocked by the firewall.

Inbound DNAT

Internet client
      │
      │ HTTP port 80
      ▼
Azure Firewall public IP
      │
      │ DNAT
      ▼
Web VM
10.20.1.4:8080

The DNAT rule translates:

Firewall public IP:80
          ↓
10.20.1.4:8080

DNS proxy

Client VM
    │
    │ DNS request
    ▼
Azure Firewall private IP:53
    │
    │ DNS proxy
    ▼
Azure DNS

When DNS proxy is enabled, the firewall listens on port 53 and forwards client DNS requests. The spoke VNet DNS setting must point to the firewall private IP, and connected VMs must be restarted to receive the updated DNS configuration.

Firewall rule plan

Collection

Type

Priority

Result

Nat-Publish-Web

DNAT

100

Firewall public port 80 to web VM port 8080

Net-Allow-EastWest

Network

200

Client subnet to web VM TCP 8080

App-Allow-Approved-Web

Application

300

HTTPS to approved FQDNs

Default action

Default deny

N/A

All other traffic denied

Azure Firewall uses a default-deny model. Rules are terminating, and processing occurs by rule type: DNAT rules first, network rules second, and application rules last. A matching DNAT rule performs the translation and allows the connection without processing it through later network rules.

Classic rules and Firewall Policy

This workshop uses classic firewall rule commands because each command maps directly to the individual application, network, and DNAT concepts being demonstrated.

Azure Firewall supports both classic rules and Firewall Policy. Firewall Policy is the recommended configuration model for centralized management, multiple firewalls, policy inheritance, and advanced Premium features.

1. Verify the prerequisites

You need:

  • PowerShell 7;

  • Azure CLI;

  • an Azure subscription;

  • permission to create networking and compute resources;

  • quota for Azure Firewall and two small virtual machines.

Verify PowerShell:

pwsh --version

Verify Azure CLI:

az version

Confirm that both commands are available:

$RequiredCommands = @(
    "pwsh"
    "az"
)

foreach ($Command in $RequiredCommands) {
    $ResolvedCommand = Get-Command `
        -Name $Command `
        -ErrorAction SilentlyContinue

    if (-not $ResolvedCommand) {
        throw "Required command '$Command' was not found."
    }

    [pscustomobject]@{
        Command = $Command
        Path    = $ResolvedCommand.Source
    }
}

2. Sign in to Azure

Authenticate:

az login

List the subscriptions available to your identity:

az account list `
    --query "[].{
        Name:name,
        SubscriptionId:id,
        TenantId:tenantId,
        IsDefault:isDefault
    }" `
    --output table

Select the target subscription:

$SubscriptionId = "<your-subscription-id>"

az account set `
    --subscription $SubscriptionId

Confirm the active Azure context:

az account show `
    --query "{
        Subscription:name,
        SubscriptionId:id,
        TenantId:tenantId,
        User:user.name
    }" `
    --output table

Register the required resource providers:

az provider register `
    --namespace Microsoft.Network `
    --wait

az provider register `
    --namespace Microsoft.OperationalInsights `
    --wait

az provider register `
    --namespace Microsoft.Insights `
    --wait

3. Install the Azure Firewall CLI extension

The current az network firewall command group is delivered by the azure-firewall Azure CLI extension and requires Azure CLI version 2.75.0 or later.

Check whether the extension is installed:

$FirewallExtension = az extension list `
    --query "[?name=='azure-firewall'].name | [0]" `
    --output tsv

Install or update it:

if ([string]::IsNullOrWhiteSpace($FirewallExtension)) {
    az extension add `
        --name azure-firewall `
        --upgrade `
        --only-show-errors
}
else {
    az extension update `
        --name azure-firewall `
        --only-show-errors
}

Display the installed version:

az extension show `
    --name azure-firewall `
    --query "{
        Name:name,
        Version:version
    }" `
    --output table

Verify the command groups used in the workshop:

az network firewall --help
az network firewall application-rule --help
az network firewall network-rule --help
az network firewall nat-rule --help

4. Define the workshop variables

Keep the same PowerShell session open throughout the workshop.

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$SubscriptionId = (
    az account show `
        --query id `
        --output tsv
).Trim()

$TenantId = (
    az account show `
        --query tenantId `
        --output tsv
).Trim()

if ([string]::IsNullOrWhiteSpace($SubscriptionId)) {
    throw "No active Azure subscription was found."
}

$Location      = "westeurope"
$ResourceGroup = "rg-firewall-deepdive-weu"

# Hub network

$HubVnetName           = "vnet-hub-firewall-weu"
$HubVnetPrefix         = "10.0.0.0/16"
$FirewallSubnetName    = "AzureFirewallSubnet"
$FirewallSubnetPrefix  = "10.0.0.0/26"

# Client spoke

$ClientVnetName       = "vnet-spoke-client-firewall-weu"
$ClientVnetPrefix     = "10.10.0.0/16"
$ClientSubnetName     = "snet-client"
$ClientSubnetPrefix   = "10.10.1.0/24"
$ClientVmName         = "vm-firewall-client"
$ClientVmNic          = "nic-firewall-client"
$ClientVmPublicIpName = "pip-firewall-client"
$ClientVmPrivateIp    = "10.10.1.4"

# Web spoke

$WebVnetName       = "vnet-spoke-web-firewall-weu"
$WebVnetPrefix     = "10.20.0.0/16"
$WebSubnetName     = "snet-web"
$WebSubnetPrefix   = "10.20.1.0/24"
$WebVmName         = "vm-firewall-web"
$WebVmNic          = "nic-firewall-web"
$WebVmPublicIpName = "pip-firewall-web"
$WebVmPrivateIp    = "10.20.1.4"

# Azure Firewall

$FirewallName         = "fw-hub-deepdive-weu"
$FirewallPublicIpName = "pip-fw-hub-deepdive-weu"
$FirewallIpConfigName = "fw-ipconfig"

# VNet peerings

$HubToClientPeering = "peer-hub-to-client"
$ClientToHubPeering = "peer-client-to-hub"

$HubToWebPeering = "peer-hub-to-web"
$WebToHubPeering = "peer-web-to-hub"

# Network security groups

$ClientNsgName = "nsg-firewall-client"
$WebNsgName    = "nsg-firewall-web"

# Route tables

$ClientRouteTableName = "rt-client-to-firewall"
$WebRouteTableName    = "rt-web-to-firewall"

$ClientDefaultRouteName = "route-default-to-firewall"
$WebDefaultRouteName    = "route-default-to-firewall"

# Firewall rule collections

$NatRuleCollection         = "Nat-Publish-Web"
$NetworkRuleCollection     = "Net-Allow-EastWest"
$ApplicationRuleCollection = "App-Allow-Approved-Web"

# Diagnostics

$LogAnalyticsWorkspace = "law-firewall-deepdive-weu"
$DiagnosticSettingName = "diag-firewall-to-log-analytics"

# Virtual machines

$VmSize          = "Standard_B1s"
$VmAdminUsername = "azureuser"
$UbuntuImage     = "Ubuntu2204"

az account set `
    --subscription $SubscriptionId

Review the selected configuration:

[pscustomobject]@{
    SubscriptionId  = $SubscriptionId
    TenantId        = $TenantId
    Location        = $Location
    ResourceGroup   = $ResourceGroup
    HubVnet         = $HubVnetName
    ClientVnet      = $ClientVnetName
    WebVnet         = $WebVnetName
    Firewall        = $FirewallName
    FirewallSku     = "Standard"
}

5. Check for an existing workshop deployment

$ResourceGroupExists = az group exists `
    --subscription $SubscriptionId `
    --name $ResourceGroup `
    --output tsv

if ($ResourceGroupExists -eq "true") {
    throw @"
Resource group '$ResourceGroup' already exists.

Delete the previous workshop or change the resource-group name.
"@
}

6. Create the resource group

az group create `
    --subscription $SubscriptionId `
    --name $ResourceGroup `
    --location $Location `
    --tags `
        workshop=azure-firewall-deep-dive `
        environment=lab `
        managedBy=azure-cli `
    --only-show-errors `
    --output none

Verify it:

az group show `
    --subscription $SubscriptionId `
    --name $ResourceGroup `
    --query "{
        Name:name,
        Location:location,
        State:properties.provisioningState,
        Tags:tags
    }" `
    --output jsonc

7. Create the Log Analytics workspace

az monitor log-analytics workspace create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --workspace-name $LogAnalyticsWorkspace `
    --location $Location `
    --tags `
        workshop=azure-firewall-deep-dive `
        managedBy=azure-cli `
    --only-show-errors `
    --output none

Wait for the workspace:

az monitor log-analytics workspace wait `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --workspace-name $LogAnalyticsWorkspace `
    --created `
    --interval 10 `
    --timeout 600

Display it:

az monitor log-analytics workspace show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --workspace-name $LogAnalyticsWorkspace `
    --query "{
        Name:name,
        Location:location,
        State:provisioningState,
        CustomerId:customerId,
        Sku:sku.name
    }" `
    --output jsonc

8. Create the hub VNet

Create the hub and the required firewall subnet:

az network vnet create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $HubVnetName `
    --location $Location `
    --address-prefixes $HubVnetPrefix `
    --subnet-name $FirewallSubnetName `
    --subnet-prefixes $FirewallSubnetPrefix `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=security-hub `
        managedBy=azure-cli `
    --only-show-errors `
    --output none

Verify the firewall subnet:

az network vnet subnet show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $HubVnetName `
    --name $FirewallSubnetName `
    --query "{
        Name:name,
        Prefix:addressPrefix,
        NetworkSecurityGroup:networkSecurityGroup.id,
        RouteTable:routeTable.id
    }" `
    --output jsonc

Required values:

Name                   AzureFirewallSubnet
Prefix                 10.0.0.0/26
NetworkSecurityGroup   Empty
RouteTable             Empty

Do not associate an NSG or route table with AzureFirewallSubnet.

9. Create the client spoke VNet

az network vnet create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVnetName `
    --location $Location `
    --address-prefixes $ClientVnetPrefix `
    --subnet-name $ClientSubnetName `
    --subnet-prefixes $ClientSubnetPrefix `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=client-spoke `
        managedBy=azure-cli `
    --only-show-errors `
    --output none

10. Create the web spoke VNet

az network vnet create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVnetName `
    --location $Location `
    --address-prefixes $WebVnetPrefix `
    --subnet-name $WebSubnetName `
    --subnet-prefixes $WebSubnetPrefix `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=web-spoke `
        managedBy=azure-cli `
    --only-show-errors `
    --output none

Display all three VNets:

az network vnet list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --query "[].{
        Name:name,
        AddressSpace:addressSpace.addressPrefixes[0],
        Subnets:length(subnets),
        Role:tags.role
    }" `
    --output table

11. Create the hub-and-spoke peerings

Resolve the VNet resource IDs:

$HubVnetId = (
    az network vnet show `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --name $HubVnetName `
        --query id `
        --output tsv
).Trim()

$ClientVnetId = (
    az network vnet show `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --name $ClientVnetName `
        --query id `
        --output tsv
).Trim()

$WebVnetId = (
    az network vnet show `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --name $WebVnetName `
        --query id `
        --output tsv
).Trim()

Create the hub-to-client peering:

az network vnet peering create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $HubVnetName `
    --name $HubToClientPeering `
    --remote-vnet $ClientVnetId `
    --allow-vnet-access true `
    --allow-forwarded-traffic true `
    --only-show-errors `
    --output none

Create the client-to-hub peering:

az network vnet peering create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $ClientVnetName `
    --name $ClientToHubPeering `
    --remote-vnet $HubVnetId `
    --allow-vnet-access true `
    --allow-forwarded-traffic true `
    --only-show-errors `
    --output none

Create the hub-to-web peering:

az network vnet peering create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $HubVnetName `
    --name $HubToWebPeering `
    --remote-vnet $WebVnetId `
    --allow-vnet-access true `
    --allow-forwarded-traffic true `
    --only-show-errors `
    --output none

Create the web-to-hub peering:

az network vnet peering create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $WebVnetName `
    --name $WebToHubPeering `
    --remote-vnet $HubVnetId `
    --allow-vnet-access true `
    --allow-forwarded-traffic true `
    --only-show-errors `
    --output none

There is deliberately no direct peering between the two spokes.

Wait for the peerings

$PeeringDefinitions = @(
    [pscustomobject]@{
        Vnet = $HubVnetName
        Name = $HubToClientPeering
    }

    [pscustomobject]@{
        Vnet = $ClientVnetName
        Name = $ClientToHubPeering
    }

    [pscustomobject]@{
        Vnet = $HubVnetName
        Name = $HubToWebPeering
    }

    [pscustomobject]@{
        Vnet = $WebVnetName
        Name = $WebToHubPeering
    }
)

foreach ($Peering in $PeeringDefinitions) {
    az network vnet peering wait `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --vnet-name $Peering.Vnet `
        --name $Peering.Name `
        --custom "peeringState=='Connected'" `
        --interval 15 `
        --timeout 600
}

Verify the hub peerings:

az network vnet peering list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $HubVnetName `
    --query "[].{
        Name:name,
        State:peeringState,
        ForwardedTraffic:allowForwardedTraffic,
        RemoteVnet:remoteVirtualNetwork.id
    }" `
    --output table

12. Create the network security groups

Create the client NSG:

az network nsg create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientNsgName `
    --location $Location `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=client-security `
    --only-show-errors `
    --output none

No inbound Internet rule is created for the client.

Create the web NSG:

az network nsg create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebNsgName `
    --location $Location `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=web-security `
    --only-show-errors `
    --output none

Allow the client subnet to reach the web service:

az network nsg rule create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --nsg-name $WebNsgName `
    --name Allow-Client-HTTP-8080 `
    --priority 100 `
    --direction Inbound `
    --access Allow `
    --protocol Tcp `
    --source-address-prefixes $ClientSubnetPrefix `
    --source-port-ranges "*" `
    --destination-address-prefixes "*" `
    --destination-port-ranges 8080 `
    --description "Allow the client subnet to reach the web test service." `
    --only-show-errors `
    --output none

Allow the client subnet to reach SSH at the NSG layer:

az network nsg rule create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --nsg-name $WebNsgName `
    --name Allow-Client-SSH-For-Firewall-Test `
    --priority 110 `
    --direction Inbound `
    --access Allow `
    --protocol Tcp `
    --source-address-prefixes $ClientSubnetPrefix `
    --source-port-ranges "*" `
    --destination-address-prefixes "*" `
    --destination-port-ranges 22 `
    --description "Allow SSH at the NSG layer so Azure Firewall can demonstrate the block." `
    --only-show-errors `
    --output none

Allow the firewall subnet to reach the published web service:

az network nsg rule create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --nsg-name $WebNsgName `
    --name Allow-Firewall-DNAT-HTTP-8080 `
    --priority 120 `
    --direction Inbound `
    --access Allow `
    --protocol Tcp `
    --source-address-prefixes $FirewallSubnetPrefix `
    --source-port-ranges "*" `
    --destination-address-prefixes "*" `
    --destination-port-ranges 8080 `
    --description "Allow the Azure Firewall DNAT path to reach the web service." `
    --only-show-errors `
    --output none

The web VM does not permit arbitrary Internet traffic to port 8080.

13. Create VM public IP addresses

The public IP addresses provide a predictable bootstrap and management path for the Azure VM agent.

The NSGs do not expose SSH or web access directly from the Internet.

$VmPublicIpNames = @(
    $ClientVmPublicIpName
    $WebVmPublicIpName
)

foreach ($PublicIpName in $VmPublicIpNames) {
    az network public-ip create `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --name $PublicIpName `
        --location $Location `
        --sku Standard `
        --tier Regional `
        --allocation-method Static `
        --version IPv4 `
        --tags `
            workshop=azure-firewall-deep-dive `
            role=vm-bootstrap `
        --only-show-errors `
        --output none
}

14. Create the VM network interfaces

Create the client NIC:

az network nic create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmNic `
    --location $Location `
    --vnet-name $ClientVnetName `
    --subnet $ClientSubnetName `
    --private-ip-address $ClientVmPrivateIp `
    --public-ip-address $ClientVmPublicIpName `
    --network-security-group $ClientNsgName `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=firewall-client `
    --only-show-errors `
    --output none

Create the web NIC:

az network nic create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVmNic `
    --location $Location `
    --vnet-name $WebVnetName `
    --subnet $WebSubnetName `
    --private-ip-address $WebVmPrivateIp `
    --public-ip-address $WebVmPublicIpName `
    --network-security-group $WebNsgName `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=firewall-web `
    --only-show-errors `
    --output none

15. Deploy the virtual machines

Create the client VM:

az vm create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --location $Location `
    --nics $ClientVmNic `
    --image $UbuntuImage `
    --size $VmSize `
    --admin-username $VmAdminUsername `
    --authentication-type ssh `
    --generate-ssh-keys `
    --security-type Standard `
    --os-disk-sku Standard_LRS `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=firewall-client `
    --no-wait `
    --only-show-errors `
    --output none

Create the web VM:

az vm create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVmName `
    --location $Location `
    --nics $WebVmNic `
    --image $UbuntuImage `
    --size $VmSize `
    --admin-username $VmAdminUsername `
    --authentication-type ssh `
    --generate-ssh-keys `
    --security-type Standard `
    --os-disk-sku Standard_LRS `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=firewall-web `
    --no-wait `
    --only-show-errors `
    --output none

Wait for both VMs:

foreach ($VmName in @(
    $ClientVmName
    $WebVmName
)) {
    az vm wait `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --name $VmName `
        --created `
        --interval 15 `
        --timeout 1800
}

Display their status:

az vm list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --show-details `
    --query "[].{
        Name:name,
        PowerState:powerState,
        PrivateIp:privateIps,
        PublicIp:publicIps,
        Size:hardwareProfile.vmSize
    }" `
    --output table

16. Prepare the client VM

The client requires:

  • curl;

  • dig and nslookup;

  • netcat;

  • jq.

$ClientBootstrapScript = @'
#!/usr/bin/env bash
set -euo pipefail

export DEBIAN_FRONTEND=noninteractive

apt-get update

apt-get install -y \
    curl \
    dnsutils \
    jq \
    netcat-openbsd

echo
echo "Installed tools:"
curl --version | head -n 1
dig -v
nc -h 2>&1 | head -n 1 || true
'@

Run it:

az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts $ClientBootstrapScript `
    --query "value[0].message" `
    --output tsv

17. Prepare the web VM

Install Nginx and configure it to listen on TCP port 8080.

$WebBootstrapScript = @'
#!/usr/bin/env bash
set -euo pipefail

export DEBIAN_FRONTEND=noninteractive

apt-get update
apt-get install -y nginx

cat >/etc/nginx/sites-available/default <<'NGINX'
server {
    listen 8080 default_server;
    listen [::]:8080 default_server;

    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
NGINX

cat >/var/www/html/index.html <<'HTML'
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Azure Firewall Deep Dive</title>
    <style>
        body {
            background: #08131f;
            color: #79e8ff;
            font-family: Arial, sans-serif;
            margin: 4rem;
        }

        .card {
            border: 2px solid #ff3fcf;
            border-radius: 12px;
            padding: 2rem;
            max-width: 700px;
            box-shadow: 0 0 24px #ff3fcf55;
        }

        h1 {
            color: #ff55d7;
        }

        code {
            color: #ffffff;
        }
    </style>
</head>
<body>
    <div class="card">
        <h1>Azure Firewall DNAT Demo</h1>
        <p>The request reached the private web VM through Azure Firewall.</p>
        <p>Backend: <code>10.20.1.4:8080</code></p>
    </div>
</body>
</html>
HTML

nginx -t
systemctl enable nginx
systemctl restart nginx

echo
echo "Nginx listener:"
ss -lntp | grep ':8080'
'@

Run it:

az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVmName `
    --command-id RunShellScript `
    --scripts $WebBootstrapScript `
    --query "value[0].message" `
    --output tsv

18. Verify the private web service

Test the service locally on the web VM:

az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVmName `
    --command-id RunShellScript `
    --scripts "curl -fsS http://127.0.0.1:8080" `
    --query "value[0].message" `
    --output tsv

Expected content includes:

Azure Firewall DNAT Demo

19. Create the Azure Firewall public IP address

az network public-ip create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallPublicIpName `
    --location $Location `
    --sku Standard `
    --tier Regional `
    --allocation-method Static `
    --version IPv4 `
    --zone 1 2 3 `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=azure-firewall `
    --only-show-errors `
    --output none

Verify the public IP:

az network public-ip show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallPublicIpName `
    --query "{
        Name:name,
        IpAddress:ipAddress,
        Allocation:publicIPAllocationMethod,
        Sku:sku.name,
        Zones:zones,
        State:provisioningState
    }" `
    --output jsonc

20. Deploy Azure Firewall

Create the firewall resource:

az network firewall create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallName `
    --location $Location `
    --sku AZFW_VNet `
    --tier Standard `
    --zones 1 2 3 `
    --threat-intel-mode Alert `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=central-firewall `
        managedBy=azure-cli `
    --only-show-errors `
    --output none

Create the firewall IP configuration:

az network firewall ip-config create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --name $FirewallIpConfigName `
    --public-ip-address $FirewallPublicIpName `
    --vnet-name $HubVnetName `
    --only-show-errors `
    --output none

Enable DNS proxy:

az network firewall update `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallName `
    --enable-dns-proxy true `
    --only-show-errors `
    --output none

Wait for the firewall:

az network firewall wait `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallName `
    --custom "provisioningState=='Succeeded'" `
    --interval 30 `
    --timeout 3600

21. Resolve the firewall IP addresses

Get the private IP:

$FirewallPrivateIp = (
    az network firewall ip-config list `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --firewall-name $FirewallName `
        --query "[?name=='$FirewallIpConfigName'].privateIpAddress | [0]" `
        --output tsv
).Trim()

Get the public IP:

$FirewallPublicIp = (
    az network public-ip show `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --name $FirewallPublicIpName `
        --query ipAddress `
        --output tsv
).Trim()

Validate both values:

if ([string]::IsNullOrWhiteSpace($FirewallPrivateIp)) {
    throw "Azure Firewall did not return a private IP address."
}

if ([string]::IsNullOrWhiteSpace($FirewallPublicIp)) {
    throw "Azure Firewall did not return a public IP address."
}

Display them:

[pscustomobject]@{
    Firewall        = $FirewallName
    PrivateIp       = $FirewallPrivateIp
    PublicIp        = $FirewallPublicIp
    DnsProxyEnabled = $true
}

22. Configure DNS proxy for the spoke VNets

Configure the client VNet to use the firewall as its DNS server:

az network vnet update `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVnetName `
    --dns-servers $FirewallPrivateIp `
    --only-show-errors `
    --output none

Configure the web VNet:

az network vnet update `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVnetName `
    --dns-servers $FirewallPrivateIp `
    --only-show-errors `
    --output none

Display the DNS configuration:

az network vnet show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVnetName `
    --query "{
        Vnet:name,
        DnsServers:dhcpOptions.dnsServers
    }" `
    --output jsonc

az network vnet show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVnetName `
    --query "{
        Vnet:name,
        DnsServers:dhcpOptions.dnsServers
    }" `
    --output jsonc

Restart both VMs so they receive the new DNS configuration:

az vm restart `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --only-show-errors

az vm restart `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVmName `
    --only-show-errors

23. Create the network rule

Allow the client subnet to reach the private web service on TCP port 8080.

az network firewall network-rule create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $NetworkRuleCollection `
    --name Allow-Client-To-Web-8080 `
    --protocols TCP `
    --source-addresses $ClientSubnetPrefix `
    --destination-addresses $WebVmPrivateIp `
    --destination-ports 8080 `
    --priority 200 `
    --action Allow `
    --description "Allow the client spoke to reach the private web service." `
    --only-show-errors `
    --output none

No network rule is created for TCP port 22.

The firewall therefore denies client-to-web SSH even though the web NSG permits it.

24. Create the application rule

Allow outbound HTTPS access to the approved FQDNs:

az network firewall application-rule create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $ApplicationRuleCollection `
    --name Allow-Approved-Https `
    --protocols Https=443 `
    --source-addresses $ClientSubnetPrefix `
    --target-fqdns `
        www.microsoft.com `
        api.ipify.org `
    --priority 300 `
    --action Allow `
    --description "Allow approved HTTPS destinations from the client spoke." `
    --only-show-errors `
    --output none

No application rule is created for:

It remains blocked by the firewall’s default-deny behavior.

25. Create the DNAT rule

Publish the private web service through the firewall public IP:

az network firewall nat-rule create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $NatRuleCollection `
    --name Publish-Web-HTTP `
    --protocols TCP `
    --source-addresses "*" `
    --destination-addresses $FirewallPublicIp `
    --destination-ports 80 `
    --translated-address $WebVmPrivateIp `
    --translated-port 8080 `
    --priority 100 `
    --action Dnat `
    --description "Publish the private web service through the firewall public IP." `
    --only-show-errors `
    --output none
Lab-only source rangeThe DNAT source is set to * so the workshop can be tested from an arbitrary Internet connection.In production, use known public source ranges or another controlled ingress architecture. DNAT rules are processed before network rules, and a matching DNAT rule implicitly allows the translated connection.

26. Review the firewall rules

List network rules:

az network firewall network-rule list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $NetworkRuleCollection `
    --output jsonc

List application rules:

az network firewall application-rule list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $ApplicationRuleCollection `
    --output jsonc

List DNAT rules:

az network firewall nat-rule list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $NatRuleCollection `
    --output jsonc

27. Create the spoke route tables

Create the client route table:

az network route-table create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientRouteTableName `
    --location $Location `
    --disable-bgp-route-propagation true `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=client-egress `
    --only-show-errors `
    --output none

Create the web route table:

az network route-table create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebRouteTableName `
    --location $Location `
    --disable-bgp-route-propagation true `
    --tags `
        workshop=azure-firewall-deep-dive `
        role=web-egress `
    --only-show-errors `
    --output none

28. Add the default routes

Create the client default route:

az network route-table route create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --route-table-name $ClientRouteTableName `
    --name $ClientDefaultRouteName `
    --address-prefix 0.0.0.0/0 `
    --next-hop-type VirtualAppliance `
    --next-hop-ip-address $FirewallPrivateIp `
    --only-show-errors `
    --output none

Create the web default route:

az network route-table route create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --route-table-name $WebRouteTableName `
    --name $WebDefaultRouteName `
    --address-prefix 0.0.0.0/0 `
    --next-hop-type VirtualAppliance `
    --next-hop-ip-address $FirewallPrivateIp `
    --only-show-errors `
    --output none

29. Associate the route tables with the spoke subnets

Associate the client route table:

az network vnet subnet update `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $ClientVnetName `
    --name $ClientSubnetName `
    --route-table $ClientRouteTableName `
    --only-show-errors `
    --output none

Associate the web route table:

az network vnet subnet update `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $WebVnetName `
    --name $WebSubnetName `
    --route-table $WebRouteTableName `
    --only-show-errors `
    --output none

All spoke Internet traffic now uses Azure Firewall as the next hop.

30. Enable Azure Firewall diagnostics

Resolve the firewall resource ID:

$FirewallResourceId = (
    az network firewall show `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --name $FirewallName `
        --query id `
        --output tsv
).Trim()

Resolve the Log Analytics workspace resource ID:

$WorkspaceResourceId = (
    az monitor log-analytics workspace show `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --workspace-name $LogAnalyticsWorkspace `
        --query id `
        --output tsv
).Trim()

Resolve the workspace customer ID:

$WorkspaceCustomerId = (
    az monitor log-analytics workspace show `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --workspace-name $LogAnalyticsWorkspace `
        --query customerId `
        --output tsv
).Trim()

Display the supported diagnostic categories:

az monitor diagnostic-settings categories list `
    --resource $FirewallResourceId `
    --output table

Create the diagnostic setting:

$LogsConfiguration = @'
[
  {
    "categoryGroup": "allLogs",
    "enabled": true
  }
]
'@

$MetricsConfiguration = @'
[
  {
    "category": "AllMetrics",
    "enabled": true
  }
]
'@
az monitor diagnostic-settings create `
    --name $DiagnosticSettingName `
    --resource $FirewallResourceId `
    --workspace $WorkspaceResourceId `
    --logs $LogsConfiguration `
    --metrics $MetricsConfiguration `
    --export-to-resource-specific true `
    --only-show-errors `
    --output none

Resource-specific mode sends Azure Firewall records to structured tables such as AZFWNetworkRule, AZFWApplicationRule, AZFWNatRule, and AZFWDnsQuery.

Verify the diagnostic setting:

az monitor diagnostic-settings show `
    --name $DiagnosticSettingName `
    --resource $FirewallResourceId `
    --output jsonc

31. Allow the configuration to converge

Write-Host `
    "Waiting for route and firewall rule propagation..." `
    -ForegroundColor Yellow

Start-Sleep -Seconds 90

32. Validate the firewall control plane

Display the firewall:

az network firewall show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallName `
    --query "{
        Name:name,
        State:provisioningState,
        Tier:sku.tier,
        Sku:sku.name,
        DnsProxy:enableDnsProxy,
        ThreatIntel:threatIntelMode,
        Zones:zones
    }" `
    --output jsonc

Expected values include:

State         Succeeded
Tier          Standard
DnsProxy      true
ThreatIntel   Alert

Display its IP configuration:

az network firewall ip-config list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --query "[].{
        Name:name,
        PrivateIp:privateIpAddress,
        PublicIp:publicIPAddress.id
    }" `
    --output table

33. Validate the spoke routes

Display the client default route:

az network route-table route list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --route-table-name $ClientRouteTableName `
    --query "[].{
        Name:name,
        Prefix:addressPrefix,
        NextHopType:nextHopType,
        NextHopIp:nextHopIpAddress
    }" `
    --output table

Display the web default route:

az network route-table route list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --route-table-name $WebRouteTableName `
    --query "[].{
        Name:name,
        Prefix:addressPrefix,
        NextHopType:nextHopType,
        NextHopIp:nextHopIpAddress
    }" `
    --output table

Expected next hop:

0.0.0.0/0
VirtualAppliance
<Azure Firewall private IP>

Inspect the client NIC effective route

az network nic show-effective-route-table `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmNic `
    --query "[?addressPrefix=='0.0.0.0/0']" `
    --output jsonc

The preferred default route should use:

Next hop type: VirtualAppliance
Next hop IP:   Azure Firewall private IP

Inspect the web NIC effective route

az network nic show-effective-route-table `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVmNic `
    --query "[?addressPrefix=='0.0.0.0/0']" `
    --output jsonc

34. Test the DNS proxy

Use dig to query the firewall private IP directly:

$DnsProxyTestScript = @"
set -x

echo "Configured resolver:"
resolvectl dns || true

echo
echo "Microsoft resolution through Azure Firewall:"
dig @$FirewallPrivateIp www.microsoft.com +short

echo
echo "Google resolution through Azure Firewall:"
dig @$FirewallPrivateIp www.google.com +short
"@

Run the test:

az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts $DnsProxyTestScript `
    --query "value[0].message" `
    --output tsv

Both names should resolve.

DNS resolution is separate from web access. Resolving www.google.com does not mean HTTPS access to it is permitted.

35. Test the allowed application rule

Test HTTPS access to Microsoft:

$AllowedApplicationResult = az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts "curl -fsS --connect-timeout 10 --max-time 20 -o /dev/null -w 'HTTP_STATUS=%{http_code}\n' https://www.microsoft.com" `
    --query "value[0].message" `
    --output tsv

Display the result:

$AllowedApplicationResult

Expected result:

HTTP_STATUS=200

A redirect response such as 301 or 302 also confirms that the allowed FQDN was reached.

36. Test the blocked application flow

Create the blocked-flow test:

$BlockedApplicationScript = @'
if curl \
    --silent \
    --show-error \
    --connect-timeout 8 \
    --max-time 15 \
    --output /dev/null \
    https://www.google.com
then
    echo "BLOCK_TEST=FAIL"
else
    echo "BLOCK_TEST=PASS"
fi
'@

Run it:

$BlockedApplicationResult = az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts $BlockedApplicationScript `
    --query "value[0].message" `
    --output tsv

Display the result:

$BlockedApplicationResult

Validate the result:

if ($BlockedApplicationResult -notmatch "BLOCK_TEST=PASS") {
    throw @"
The Google HTTPS request was unexpectedly allowed.

Review the firewall routes and rule collections.
"@
}

Write-Host `
    "PASS: The unapproved FQDN was blocked." `
    -ForegroundColor Green

37. Confirm centralized Internet egress

The client VM has a public IP for bootstrap, but its default route now points to Azure Firewall.

Use the approved IP-check FQDN:

$EgressResult = az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts "printf 'EGRESS_IP='; curl -fsS --connect-timeout 10 --max-time 20 https://api.ipify.org; echo" `
    --query "value[0].message" `
    --output tsv

Display the result:

$EgressResult

Compare it with the firewall public IP:

[pscustomobject]@{
    ExpectedFirewallPublicIp = $FirewallPublicIp
    ClientTestOutput         = $EgressResult
}

Validate it:

if (
    $EgressResult -notmatch (
        [regex]::Escape(
            $FirewallPublicIp
        )
    )
) {
    throw @"
The observed egress IP does not match the Azure Firewall public IP.

Review the client subnet route table.
"@
}

Write-Host `
    "PASS: Internet egress is centralized through Azure Firewall." `
    -ForegroundColor Green

38. Test the allowed network rule

Test TCP port 8080 from the client VM to the private web VM:

$AllowedNetworkResult = az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts "curl -fsS --connect-timeout 10 --max-time 20 http://$($WebVmPrivateIp):8080" `
    --query "value[0].message" `
    --output tsv

Display the result:

$AllowedNetworkResult

Expected content includes:

Azure Firewall DNAT Demo

Validate it:

if ($AllowedNetworkResult -notmatch "Azure Firewall DNAT Demo") {
    throw "The allowed east-west network flow failed."
}

Write-Host `
    "PASS: Client-to-web TCP 8080 was allowed." `
    -ForegroundColor Green

39. Test the blocked network flow

The web NSG permits SSH from the client subnet.

Azure Firewall does not.

Create the test:

$BlockedSshScript = @"
if nc -zvw5 $WebVmPrivateIp 22
then
    echo "SSH_BLOCK_TEST=FAIL"
else
    echo "SSH_BLOCK_TEST=PASS"
fi
"@

Run it:

$BlockedSshResult = az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts $BlockedSshScript `
    --query "value[0].message" `
    --output tsv

Display the result:

$BlockedSshResult

Validate it:

if ($BlockedSshResult -notmatch "SSH_BLOCK_TEST=PASS") {
    throw "TCP port 22 was unexpectedly reachable."
}

Write-Host `
    "PASS: Client-to-web TCP 22 was blocked by Azure Firewall." `
    -ForegroundColor Green

40. Test inbound DNAT

Display the published URL:

$PublishedUrl = "http://$FirewallPublicIp/"

$PublishedUrl

Test it from the local PowerShell terminal:

$DnatResponse = Invoke-WebRequest `
    -Uri $PublishedUrl `
    -TimeoutSec 30

Display the HTTP status:

$DnatResponse.StatusCode

Display the page content:

$DnatResponse.Content

Validate it:

if ($DnatResponse.Content -notmatch "Azure Firewall DNAT Demo") {
    throw "The DNAT test did not return the expected web page."
}

Write-Host `
    "PASS: Azure Firewall translated public port 80 to private port 8080." `
    -ForegroundColor Green

The traffic path is:

Local workstation
      ↓
Azure Firewall public IP:80
      ↓
DNAT rule
      ↓
10.20.1.4:8080

41. Deep dive: demonstrate rule-processing order

The current application rules allow:

www.microsoft.com
api.ipify.org

They do not allow:

However, network rules are processed before application rules.

A broad network rule allowing TCP 443 to any destination will permit the connection before the application rule collection is evaluated.

Create a temporary broad network rule

$TemporaryNetworkCollection = "Net-Temporary-Https-Bypass"
az network firewall network-rule create `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $TemporaryNetworkCollection `
    --name Allow-Any-Https-Temporary `
    --protocols TCP `
    --source-addresses $ClientSubnetPrefix `
    --destination-addresses "*" `
    --destination-ports 443 `
    --priority 150 `
    --action Allow `
    --description "Temporary workshop rule demonstrating network-rule precedence." `
    --only-show-errors `
    --output none

Allow the rule to propagate:

Start-Sleep -Seconds 60

Test the previously blocked FQDN

$BypassTestScript = @'
if curl \
    --silent \
    --show-error \
    --connect-timeout 8 \
    --max-time 20 \
    --output /dev/null \
    https://www.google.com
then
    echo "BYPASS_TEST=PASS"
else
    echo "BYPASS_TEST=FAIL"
fi
'@
$BypassResult = az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts $BypassTestScript `
    --query "value[0].message" `
    --output tsv

Display it:

$BypassResult

Validate it:

if ($BypassResult -notmatch "BYPASS_TEST=PASS") {
    throw "The temporary network rule did not permit HTTPS."
}

Write-Host @"
PASS: The network rule allowed HTTPS before application-rule filtering.
"@ -ForegroundColor Yellow

This is why broad network rules can unintentionally bypass FQDN-based application controls.

Delete the temporary collection

az network firewall network-rule collection delete `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $TemporaryNetworkCollection `
    --only-show-errors `
    --output none

Wait for propagation:

Start-Sleep -Seconds 60

Confirm the FQDN is blocked again

$BlockedAgainResult = az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts $BlockedApplicationScript `
    --query "value[0].message" `
    --output tsv

Display the result:

$BlockedAgainResult

Validate it:

if ($BlockedAgainResult -notmatch "BLOCK_TEST=PASS") {
    throw "The FQDN remained reachable after the temporary rule was removed."
}

Write-Host `
    "PASS: Application filtering is active again." `
    -ForegroundColor Green

42. Query the firewall diagnostic logs

Diagnostic data can take several minutes to appear.

Write-Host `
    "Waiting for Azure Firewall log ingestion..." `
    -ForegroundColor Yellow

Start-Sleep -Seconds 300

Count events by structured table

$EventCountQuery = @'
union withsource=TableName isfuzzy=true
    AZFWNetworkRule,
    AZFWApplicationRule,
    AZFWNatRule,
    AZFWDnsQuery
| where TimeGenerated > ago(2h)
| summarize Events=count() by TableName
| order by TableName asc
'@
az monitor log-analytics query `
    --workspace $WorkspaceCustomerId `
    --analytics-query $EventCountQuery `
    --timespan PT2H `
    --output table

Expected tables include:

AZFWApplicationRule
AZFWDnsQuery
AZFWNatRule
AZFWNetworkRule

Query application-rule decisions

$ApplicationLogQuery = @'
AZFWApplicationRule
| where TimeGenerated > ago(2h)
| project
    TimeGenerated,
    Action,
    ActionReason,
    SourceIp,
    Fqdn,
    DestinationPort,
    Protocol,
    RuleCollection,
    Rule
| order by TimeGenerated desc
| take 30
'@
az monitor log-analytics query `
    --workspace $WorkspaceCustomerId `
    --analytics-query $ApplicationLogQuery `
    --timespan PT2H `
    --output table

Look for:

www.microsoft.com
api.ipify.org
www.google.com

Default-deny events can show:

ActionReason = Default Action

Query network-rule decisions

$NetworkLogQuery = @'
AZFWNetworkRule
| where TimeGenerated > ago(2h)
| project
    TimeGenerated,
    Action,
    ActionReason,
    SourceIp,
    DestinationIp,
    DestinationPort,
    Protocol,
    RuleCollection,
    Rule
| order by TimeGenerated desc
| take 30
'@
az monitor log-analytics query `
    --workspace $WorkspaceCustomerId `
    --analytics-query $NetworkLogQuery `
    --timespan PT2H `
    --output table

Look for traffic to:

10.20.1.4:8080
10.20.1.4:22

Query DNAT events

$NatLogQuery = @'
AZFWNatRule
| where TimeGenerated > ago(2h)
| project
    TimeGenerated,
    SourceIp,
    DestinationIp,
    DestinationPort,
    TranslatedIp,
    TranslatedPort,
    Protocol,
    RuleCollection,
    Rule
| order by TimeGenerated desc
| take 30
'@
az monitor log-analytics query `
    --workspace $WorkspaceCustomerId `
    --analytics-query $NatLogQuery `
    --timespan PT2H `
    --output table

Expected translation:

Destination port   80
Translated IP      10.20.1.4
Translated port    8080

Query DNS proxy events

$DnsLogQuery = @'
AZFWDnsQuery
| where TimeGenerated > ago(2h)
| project
    TimeGenerated,
    SourceIp,
    QueryName,
    QueryType,
    Protocol,
    ResponseCode,
    ErrorMessage
| order by TimeGenerated desc
| take 30
'@
az monitor log-analytics query `
    --workspace $WorkspaceCustomerId `
    --analytics-query $DnsLogQuery `
    --timespan PT2H `
    --output table

Look for queries to:

www.microsoft.com
www.google.com
api.ipify.org

43. Troubleshooting

The Azure Firewall extension is unavailable

Check the Azure CLI version:

az version

Update Azure CLI:

az upgrade

Reinstall the extension:

az extension remove `
    --name azure-firewall

az extension add `
    --name azure-firewall `
    --upgrade `
    --only-show-errors

Azure Firewall creation fails

Verify the subnet:

az network vnet subnet show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $HubVnetName `
    --name $FirewallSubnetName `
    --query "{
        Name:name,
        Prefix:addressPrefix,
        Nsg:networkSecurityGroup.id,
        RouteTable:routeTable.id
    }" `
    --output jsonc

Required configuration:

Name          AzureFirewallSubnet
Prefix        /26 or larger
NSG           None
Route table   None

Verify the public IP:

az network public-ip show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallPublicIpName `
    --query "{
        Name:name,
        Sku:sku.name,
        Allocation:publicIPAllocationMethod,
        State:provisioningState,
        Zones:zones
    }" `
    --output jsonc

The firewall remains in a provisioning state

az network firewall show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallName `
    --query "{
        State:provisioningState,
        Tier:sku.tier,
        IpConfigurations:ipConfigurations
    }" `
    --output jsonc

Do not continue until the state is:

Succeeded

Internet traffic bypasses the firewall

Inspect the client NIC effective routes:

az network nic show-effective-route-table `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmNic `
    --output table

Verify the default route:

Prefix          0.0.0.0/0
Next hop type   VirtualAppliance
Next hop IP     Azure Firewall private IP

Inspect the subnet route table:

az network vnet subnet show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $ClientVnetName `
    --name $ClientSubnetName `
    --query "{
        RouteTable:routeTable.id
    }" `
    --output jsonc

Spoke-to-spoke traffic fails

Verify all peerings:

foreach ($VnetName in @(
    $HubVnetName
    $ClientVnetName
    $WebVnetName
)) {
    Write-Host "`n$VnetName" -ForegroundColor Cyan

    az network vnet peering list `
        --subscription $SubscriptionId `
        --resource-group $ResourceGroup `
        --vnet-name $VnetName `
        --query "[].{
            Name:name,
            State:peeringState,
            VnetAccess:allowVirtualNetworkAccess,
            ForwardedTraffic:allowForwardedTraffic
        }" `
        --output table
}

Required settings:

Peering state        Connected
VNet access          true
Forwarded traffic    true

Check both spoke route tables.

The client and web subnets require the firewall default route for symmetric inspection.

DNS resolution fails

Confirm DNS proxy is enabled:

az network firewall show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $FirewallName `
    --query "enableDnsProxy" `
    --output tsv

Expected result:

true

Confirm the VNet DNS server:

az network vnet show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVnetName `
    --query "dhcpOptions.dnsServers" `
    --output jsonc

Confirm the firewall private IP:

$FirewallPrivateIp

Restart the client VM again:

az vm restart `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName

Inspect the guest resolver:

az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmName `
    --command-id RunShellScript `
    --scripts @"
resolvectl status
echo
dig @$FirewallPrivateIp www.microsoft.com
"@ `
    --query "value[0].message" `
    --output tsv

DNS resolves but HTTPS is blocked

This can be expected.

DNS proxy permits name resolution. Application rules separately decide whether HTTP or HTTPS access is allowed.

List the application rules:

az network firewall application-rule list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $ApplicationRuleCollection `
    --output jsonc

Check:

  • the source subnet;

  • the target FQDN;

  • the HTTPS protocol;

  • the route to the firewall;

  • whether a broader network rule is matching first.

Application filtering does not work

List all network-rule collections:

az network firewall network-rule collection list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --output jsonc

Look for broad rules such as:

Destination: *
Port:        443
Action:      Allow

A matching network rule is processed before application rules.

Remove the unintended collection or narrow its destination and port scope.

The private web service is unreachable

Check Nginx:

az vm run-command invoke `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $WebVmName `
    --command-id RunShellScript `
    --scripts @"
systemctl is-active nginx
ss -lntp | grep ':8080' || true
curl -v http://127.0.0.1:8080
"@ `
    --query "value[0].message" `
    --output tsv

Inspect the web NSG:

az network nsg rule list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --nsg-name $WebNsgName `
    --query "[].{
        Name:name,
        Priority:priority,
        Access:access,
        Source:sourceAddressPrefix,
        DestinationPort:destinationPortRange
    }" `
    --output table

Inspect the firewall network rule:

az network firewall network-rule list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $NetworkRuleCollection `
    --output jsonc

DNAT fails

Verify the firewall public IP:

$FirewallPublicIp

Verify the NAT rule:

az network firewall nat-rule list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --firewall-name $FirewallName `
    --collection-name $NatRuleCollection `
    --output jsonc

Confirm:

Destination address   Firewall public IP
Destination port      80
Translated address    10.20.1.4
Translated port       8080
Protocol              TCP

Check the web NSG rule that allows traffic from:

10.0.0.0/26

Check the web subnet default route.

Check the web service locally.

The observed egress IP is not the firewall IP

Inspect the client effective route table:

az network nic show-effective-route-table `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --name $ClientVmNic `
    --query "[?addressPrefix=='0.0.0.0/0']" `
    --output jsonc

Verify that the active default route points to:

$FirewallPrivateIp

Confirm the route table is attached to the correct subnet:

az network vnet subnet show `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --vnet-name $ClientVnetName `
    --name $ClientSubnetName `
    --query "routeTable.id" `
    --output tsv

Diagnostic tables are empty

Confirm the diagnostic setting:

az monitor diagnostic-settings show `
    --name $DiagnosticSettingName `
    --resource $FirewallResourceId `
    --output jsonc

Generate the test traffic again.

Wait several minutes:

Start-Sleep -Seconds 300

Run the event-count query again:

az monitor log-analytics query `
    --workspace $WorkspaceCustomerId `
    --analytics-query $EventCountQuery `
    --timespan PT2H `
    --output table

Log ingestion is not immediate.

44. Production considerations

The workshop is intentionally compact. A production firewall platform requires additional design decisions.

Use Firewall Policy

Firewall Policy is generally preferred for:

  • centralized management;

  • multiple firewalls;

  • policy inheritance;

  • rule collection groups;

  • Premium features;

  • deployment through CI/CD.

Restrict DNAT sources

Replace:

*

with known public source ranges whenever possible.

For public HTTP and HTTPS workloads, also evaluate whether Application Gateway, Front Door, or another application-aware ingress service is required.

Remove direct VM public IP addresses

The VM public IP addresses in this workshop are used for simple bootstrap and agent connectivity.

A production design should normally use:

  • Azure Bastion;

  • private administration;

  • a controlled jump host;

  • Azure Arc;

  • approved remote-management tooling.

Use separate route tables by security zone

Do not reuse one route table across unrelated trust boundaries without an explicit design decision.

Possible zones include:

Application
Data
Management
Shared services
Private endpoints
Ingress
Egress

Avoid overly broad network rules

A broad network rule can bypass application-layer FQDN filtering because network rules are processed before application rules.

Prefer specific:

  • source ranges;

  • destination ranges;

  • destination ports;

  • protocols.

Plan DNS centrally

For hybrid networks, Azure Firewall DNS proxy might forward to:

  • Azure DNS;

  • Azure DNS Private Resolver;

  • domain controllers;

  • on-premises DNS servers.

Ensure the firewall and clients use a consistent resolution path.

Monitor default-deny events

A denied connection can indicate:

  • expected policy enforcement;

  • a missing rule;

  • a compromised workload;

  • an application dependency;

  • an incorrect route;

  • a DNS mismatch.

Build alerts and operational queries around high-value deny patterns.

Select the correct SKU

Azure Firewall Standard is sufficient for this workshop.

Evaluate Azure Firewall Premium when requirements include features such as:

  • intrusion detection and prevention;

  • TLS inspection;

  • URL filtering;

  • advanced web categories.

45. Cleanup

Review every resource in the workshop resource group:

az resource list `
    --subscription $SubscriptionId `
    --resource-group $ResourceGroup `
    --query "[].{
        Name:name,
        Type:type,
        Location:location
    }" `
    --output table

Require explicit confirmation:

$DeleteConfirmation = Read-Host @"
Type the resource-group name to delete the workshop:
$ResourceGroup
"@

if ($DeleteConfirmation -ne $ResourceGroup) {
    throw "Deletion cancelled."
}

Delete the complete environment:

az group delete `
    --subscription $SubscriptionId `
    --name $ResourceGroup `
    --yes `
    --no-wait `
    --only-show-errors

Monitor deletion:

$DeletionDeadline = (Get-Date).AddHours(3)

do {
    $StillExists = az group exists `
        --subscription $SubscriptionId `
        --name $ResourceGroup `
        --output tsv

    Write-Host (
        "{0:u} Resource group exists: {1}" -f `
            (Get-Date),
            $StillExists
    )

    if ($StillExists -eq "true") {
        Start-Sleep -Seconds 60
    }
}
until (
    $StillExists -eq "false" -or
    (Get-Date) -ge $DeletionDeadline
)

if ($StillExists -eq "true") {
    Write-Warning @"
Resource-group deletion is still running.

Azure Firewall deletion can take an extended period.
"@
}
else {
    Write-Host `
        "Workshop resources were deleted." `
        -ForegroundColor Green
}

Summary

This workshop created the following secured architecture:

Internet
   │
   │ Approved outbound HTTPS
   │ Inbound DNAT
   ▼
Azure Firewall
   │
   ├── DNS proxy
   ├── Application rules
   ├── Network rules
   ├── DNAT rules
   └── Structured diagnostics
          │
          ├── Client spoke
          └── Web spoke

The configured policy produced these results:

Test

Expected result

DNS query for Microsoft

Allowed

DNS query for Google

Allowed

HTTPS to Microsoft

Allowed

HTTPS to Google

Blocked

HTTPS to IP-check service

Allowed

Client Internet egress

Uses firewall public IP

Client to web TCP 8080

Allowed

Client to web TCP 22

Blocked

Firewall public port 80

DNAT to web VM port 8080

Broad temporary network rule

Bypasses application filtering

Rule removed

Application filtering restored

The complete workflow was:

Create the hub and spokes
          ↓
Create VNet peerings
          ↓
Deploy the client and web VMs
          ↓
Deploy Azure Firewall
          ↓
Enable DNS proxy
          ↓
Create application, network, and DNAT rules
          ↓
Route both spokes through the firewall
          ↓
Enable structured diagnostic logs
          ↓
Test allowed and blocked flows
          ↓
Demonstrate rule-processing order
          ↓
Query the firewall logs
          ↓
Delete the workshop

The central design principles are:

  1. Place Azure Firewall in a dedicated /26 or larger AzureFirewallSubnet.

  2. Use a hub-and-spoke topology for centralized inspection.

  3. Route workload traffic through the firewall with UDRs.

  4. Configure return routes to preserve symmetric traffic flow.

  5. Allow forwarded traffic on hub-and-spoke peerings.

  6. Use network rules for L3 and L4 controls.

  7. Use application rules for FQDN-based HTTP and HTTPS controls.

  8. Use DNAT rules for controlled inbound publishing.

  9. Point spoke DNS settings to the firewall when DNS proxy is enabled.

  10. Remember that DNS resolution and web access are separate decisions.

  11. Understand that network rules are processed before application rules.

  12. Use structured logs to investigate allowed and denied connections.

  13. Prefer Firewall Policy for production management.

  14. Restrict DNAT source ranges.

  15. Delete the billable workshop resources after testing.

 
 
 

Comments


bottom of page