- 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:
Build a secured Azure hub-and-spoke network.
Deploy Azure Firewall into AzureFirewallSubnet.
Peer workload VNets with a central hub.
Route spoke traffic through Azure Firewall.
Configure centralized Internet egress.
Create application rules for approved FQDNs.
Create network rules for private IP traffic.
Configure inbound DNAT.
Enable Azure Firewall DNS proxy.
Configure spoke VNets to use the firewall for DNS.
Validate the default-deny security model.
Understand Azure Firewall rule-processing order.
Demonstrate how a broad network rule can bypass application filtering.
Enable structured diagnostic logs.
Query firewall decisions with KQL.
Troubleshoot routing, DNS, DNAT, and rule problems.
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:
Place Azure Firewall in a dedicated /26 or larger AzureFirewallSubnet.
Use a hub-and-spoke topology for centralized inspection.
Route workload traffic through the firewall with UDRs.
Configure return routes to preserve symmetric traffic flow.
Allow forwarded traffic on hub-and-spoke peerings.
Use network rules for L3 and L4 controls.
Use application rules for FQDN-based HTTP and HTTPS controls.
Use DNAT rules for controlled inbound publishing.
Point spoke DNS settings to the firewall when DNS proxy is enabled.
Remember that DNS resolution and web access are separate decisions.
Understand that network rules are processed before application rules.
Use structured logs to investigate allowed and denied connections.
Prefer Firewall Policy for production management.
Restrict DNAT source ranges.
Delete the billable workshop resources after testing.
Comments