Skip to main content

Azure Firewall

Azure Firewall is where I centralize outbound traffic control in hub-spoke deployments. Every spoke routes through it, which means one place to write allow rules, one place to review denied flows, and one place where the audit trail lives. The tradeoff is cost — it's not cheap — and the operational overhead of managing rule collections as the estate grows.

Azure Firewall vs NSG

FeatureAzure FirewallNetwork Security Group (NSG)
OSI LayerL3-L7 (Network to Application)L3-L4 (Network & Transport)
FilteringFQDN, IP, port, protocolIP, port, protocol only
CentralizedHub-based, protects entire VNetDistributed per subnet/NIC
Threat Intelligence✅ Built-in❌ Not available
LoggingDiagnostic logs, Azure MonitorNSG Flow Logs
Cost$$$ Hourly + data processed$ Free (just storage for logs)
Use CaseHub firewall, application filteringMicro-segmentation, defense-in-depth

I use both. Azure Firewall handles hub-level traffic control; NSGs handle subnet-level micro-segmentation. They're complementary.

How Azure Firewall Works

Hub-Spoke Architecture

        Internet


[Azure Firewall] ← Public IP

┌──────┴──────┐
│ Hub VNet │
│ 10.0.0.0/16 │
└──────┬───────┘
│ Peering
┌──────┴──────┐
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Spoke 1 │ │ Spoke 2 │
│10.1.0.0 │ │10.2.0.0 │
└─────────┘ └─────────┘

Traffic Flow:

  1. Outbound: Spoke → Hub (via route table) → Azure Firewall → Internet
  2. Inbound: Internet → Azure Firewall (DNAT) → Spoke
  3. Spoke-to-Spoke: Spoke 1 → Hub → Azure Firewall → Spoke 2

Rule Processing Order

Azure Firewall processes rules in this sequence:

  1. NAT Rules (DNAT for inbound)
  2. Network Rules (Layer 3/4 filtering)
  3. Application Rules (Layer 7 FQDN filtering)

Rule processing is priority-based — first match wins. I order rules from most specific to least specific, which means the more-specific deny rules run before broad allow rules.

How to Deploy Azure Firewall

Prerequisites

# Create resource group
az group create \
--name rg-network-hub \
--location eastus

# Create hub VNet with dedicated firewall subnet
az network vnet create \
--resource-group rg-network-hub \
--name vnet-hub-prod \
--address-prefix 10.0.0.0/16 \
--subnet-name AzureFirewallSubnet \ # MUST be exactly this name
--subnet-prefix 10.0.1.0/24 # Minimum /26 subnet

Deploy Azure Firewall

Using Azure CLI

# Create public IP for firewall
az network public-ip create \
--resource-group rg-network-hub \
--name pip-firewall-prod \
--sku Standard \
--allocation-method Static

# Create Azure Firewall
az network firewall create \
--resource-group rg-network-hub \
--name afw-hub-prod \
--location eastus \
--enable-dns-proxy true

# Configure firewall with public IP and VNet
az network firewall ip-config create \
--resource-group rg-network-hub \
--firewall-name afw-hub-prod \
--name FW-Config \
--public-ip-address pip-firewall-prod \
--vnet-name vnet-hub-prod

# Get firewall private IP (needed for route tables)
FW_PRIVATE_IP=$(az network firewall show \
--resource-group rg-network-hub \
--name afw-hub-prod \
--query \"ipConfigurations[0].privateIPAddress\" -o tsv)

echo \"Firewall Private IP: $FW_PRIVATE_IP\"

Create Firewall Policy

# Create firewall policy
az network firewall policy create \
--resource-group rg-network-hub \
--name afwp-hub-prod \
--sku Standard \
--threat-intel-mode Alert # Alert, Deny, or Off

# Associate policy with firewall
az network firewall update \
--resource-group rg-network-hub \
--name afw-hub-prod \
--firewall-policy afwp-hub-prod

Configure Route Tables

# Create route table for spoke subnets
az network route-table create \
--resource-group rg-network-hub \
--name rt-spoke-to-firewall

# Add default route through firewall
az network route-table route create \
--resource-group rg-network-hub \
--route-table-name rt-spoke-to-firewall \
--name default-via-firewall \
--address-prefix 0.0.0.0/0 \
--next-hop-type VirtualAppliance \
--next-hop-ip-address $FW_PRIVATE_IP

# Associate route table with spoke subnet
az network vnet subnet update \
--resource-group rg-network-spoke1 \
--vnet-name vnet-spoke1-prod \
--name snet-app \
--route-table rt-spoke-to-firewall

Terraform Example

# Hub VNet
resource \"azurerm_virtual_network\" \"hub\" {\n name = \"vnet-hub-prod\"
address_space = [\"10.0.0.0/16\"]
location = azurerm_resource_group.hub.location
resource_group_name = azurerm_resource_group.hub.name
}

# Firewall Subnet (name MUST be AzureFirewallSubnet)
resource \"azurerm_subnet\" \"firewall\" {\n name = \"AzureFirewallSubnet\"
resource_group_name = azurerm_resource_group.hub.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = [\"10.0.1.0/24\"] # Minimum /26
}

# Public IP for Firewall
resource \"azurerm_public_ip\" \"firewall\" {\n name = \"pip-firewall-prod\"
location = azurerm_resource_group.hub.location
resource_group_name = azurerm_resource_group.hub.name
allocation_method = \"Static\"
sku = \"Standard\"
}

# Firewall Policy
resource \"azurerm_firewall_policy\" \"hub\" {\n name = \"afwp-hub-prod\"
resource_group_name = azurerm_resource_group.hub.name
location = azurerm_resource_group.hub.location
sku = \"Standard\" # Standard or Premium

threat_intelligence_mode = \"Alert\" # Alert, Deny, or Off

dns {
proxy_enabled = true # Enable DNS proxy
}

insights {
enabled = true
retention_in_days = 90
default_log_analytics_workspace_id = azurerm_log_analytics_workspace.security.id
}
}

# Azure Firewall
resource \"azurerm_firewall\" \"hub\" {\n name = \"afw-hub-prod\"
location = azurerm_resource_group.hub.location
resource_group_name = azurerm_resource_group.hub.name
sku_name = \"AZFW_VNet\"
sku_tier = \"Standard\" # Standard or Premium
firewall_policy_id = azurerm_firewall_policy.hub.id

ip_configuration {
name = \"fw-ipconfig\"
subnet_id = azurerm_subnet.firewall.id
public_ip_address_id = azurerm_public_ip.firewall.id
}
}

# Application Rule Collection (L7 filtering)
resource \"azurerm_firewall_policy_rule_collection_group\" \"app_rules\" {\n name = \"DefaultApplicationRuleCollectionGroup\"
firewall_policy_id = azurerm_firewall_policy.hub.id
priority = 100

application_rule_collection {
name = \"AllowCriticalSites\"
priority = 100
action = \"Allow\"

rule {
name = \"AllowMicrosoft\"
source_addresses = [\"10.1.0.0/16\", \"10.2.0.0/16\"] # Spoke VNets

protocols {
type = \"Https\"
port = 443
}

destination_fqdns = [
\"*.microsoft.com\",
\"*.windows.net\",
\"*.azure.com\"
]
}

rule {
name = \"AllowPackageManagers\"
source_addresses = [\"10.1.0.0/16\"]

protocols {
type = \"Https\"
port = 443
}

destination_fqdns = [
\"*.ubuntu.com\",
\"*.npmjs.org\",
\"*.pypi.org\",
\"github.com\"
]
}
}
}

# Network Rule Collection (L3/L4 filtering)
resource \"azurerm_firewall_policy_rule_collection_group\" \"network_rules\" {\n name = \"DefaultNetworkRuleCollectionGroup\"
firewall_policy_id = azurerm_firewall_policy.hub.id
priority = 200

network_rule_collection {
name = \"AllowOutbound\"
priority = 200
action = \"Allow\"

rule {
name = \"AllowDNS\"
protocols = [\"UDP\"]
source_addresses = [\"*\"]
destination_addresses = [\"8.8.8.8\", \"8.8.4.4\"] # Google DNS
destination_ports = [\"53\"]
}

rule {
name = \"AllowNTP\"
protocols = [\"UDP\"]
source_addresses = [\"*\"]
destination_fqdns = [\"time.windows.com\"]
destination_ports = [\"123\"]
}
}
}

# NAT Rule Collection (Inbound DNAT)
resource \"azurerm_firewall_policy_rule_collection_group\" \"nat_rules\" {\n name = \"DefaultDnatRuleCollectionGroup\"
firewall_policy_id = azurerm_firewall_policy.hub.id
priority = 300

nat_rule_collection {
name = \"InboundDNAT\"
priority = 300
action = \"Dnat\"

rule {
name = \"AllowHTTPSInbound\"
protocols = [\"TCP\"]
source_addresses = [\"*\"] # Public internet
destination_address = azurerm_public_ip.firewall.ip_address
destination_ports = [\"443\"]
translated_address = \"10.1.1.4\" # Internal web server
translated_port = \"443\"
}

rule {
name = \"AllowSSHInbound\"
protocols = [\"TCP\"]
source_addresses = [\"203.0.113.0/24\"] # Your office IP range
destination_address = azurerm_public_ip.firewall.ip_address
destination_ports = [\"22000\"] # Non-standard port
translated_address = \"10.1.2.10\" # Jump box
translated_port = \"22\"
}
}
}

# Route Table to send spoke traffic through firewall
resource \"azurerm_route_table\" \"spoke_to_firewall\" {\n name = \"rt-spoke-to-firewall\"
location = azurerm_resource_group.hub.location
resource_group_name = azurerm_resource_group.hub.name

route {
name = \"default-via-firewall\"
address_prefix = \"0.0.0.0/0\"
next_hop_type = \"VirtualAppliance\"
next_hop_in_ip_address = azurerm_firewall.hub.ip_configuration[0].private_ip_address
}
}

# Associate route table with spoke subnet
resource \"azurerm_subnet_route_table_association\" \"spoke1_app\" {\n subnet_id = azurerm_subnet.spoke1_app.id
route_table_id = azurerm_route_table.spoke_to_firewall.id
}

Using IP Groups for Management

IP Groups simplify rule management:

# Define IP groups
resource \"azurerm_ip_group\" \"spoke_vnets\" {\n name = \"ipg-spoke-vnets\"
location = azurerm_resource_group.hub.location
resource_group_name = azurerm_resource_group.hub.name

cidrs = [
\"10.1.0.0/16\",
\"10.2.0.0/16\",
\"10.3.0.0/16\"
]
}

resource \"azurerm_ip_group\" \"dmz_servers\" {\n name = \"ipg-dmz-servers\"
location = azurerm_resource_group.hub.location
resource_group_name = azurerm_resource_group.hub.name

cidrs = [
\"10.1.1.4/32\",
\"10.1.1.5/32\"
]
}

# Use IP groups in rules
application_rule_collection {
name = \"AllowDMZ\"
priority = 110
action = \"Allow\"

rule {
name = \"DMZOutbound\"
source_ip_groups = [azurerm_ip_group.dmz_servers.id]

protocols {
type = \"Https\"
port = 443
}

destination_fqdns = [\"*.api.example.com\"]
}
}

CI/CD Integration

Deploy Firewall with GitHub Actions

name: Deploy Azure Firewall

on:
push:
paths:
- 'terraform/firewall/**'

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2

- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}

- name: Terraform Init
run: |\n cd terraform/firewall
terraform init

- name: Terraform Plan
run: terraform plan -out=tfplan

- name: Terraform Apply
run: terraform apply -auto-approve tfplan

Validate Firewall Rules

- name: Validate Firewall Policy
run: |\n # Check for overly permissive rules
az network firewall policy rule-collection-group list \\\n --resource-group rg-network-hub \\\n --policy-name afwp-hub-prod \\\n --query \"[].{Name:name, Priority:priority}\" -o table

# Ensure threat intelligence is enabled
THREAT_INTEL=$(az network firewall policy show \\\n --resource-group rg-network-hub \\\n --name afwp-hub-prod \\\n --query threatIntelMode -o tsv)

if [ \"$THREAT_INTEL\" != \"Alert\" ] && [ \"$THREAT_INTEL\" != \"Deny\" ]; then
echo \"ERROR: Threat Intelligence not enabled!\"
exit 1
fi

Best Practices

1. Use Firewall Policy (Not Classic Rules)

Firewall Policy (modern approach):

  • Centralized management
  • Support for rule hierarchies (parent/child policies)
  • Better for multi-firewall deployments

Classic Rules (deprecated):

  • Managed per firewall
  • Difficult to maintain at scale

2. Enable Threat Intelligence

threat_intelligence_mode = \"Alert\"  # or \"Deny\" for blocking

Alert = Log suspicious traffic Deny = Block malicious IPs/domains (Microsoft threat feed)

3. Use DNS Proxy

dns {
proxy_enabled = true
}

Benefits:

  • Firewall resolves FQDN rules using its own DNS
  • Prevents DNS cache poisoning attacks
  • Required for FQDN-based network rules

4. Organize Rules with Priority

Priority 100-199: Critical business apps
Priority 200-299: General outbound access
Priority 300-399: Management/monitoring
Priority 400-499: Development/testing

Lower number = higher priority = processed first

5. Enable Diagnostic Logging

resource \"azurerm_monitor_diagnostic_setting\" \"firewall\" {\n  name                       = \"firewall-diagnostics\"
target_resource_id = azurerm_firewall.hub.id
log_analytics_workspace_id = azurerm_log_analytics_workspace.security.id

enabled_log {
category = \"AzureFirewallApplicationRule\"
}

enabled_log {
category = \"AzureFirewallNetworkRule\"
}

enabled_log {
category = \"AzureFirewallDnsProxy\"
}

metric {
category = \"AllMetrics\"
enabled = true
}
}

Common Use Cases

1. Hub-Spoke with Forced Tunneling

# Force all spoke traffic through firewall
resource \"azurerm_route\" \"default_route\" {\n name = \"default-via-firewall\"
resource_group_name = azurerm_resource_group.hub.name
route_table_name = azurerm_route_table.spoke.name
address_prefix = \"0.0.0.0/0\"
next_hop_type = \"VirtualAppliance\"
next_hop_in_ip_address = azurerm_firewall.hub.ip_configuration[0].private_ip_address
}

2. Allow Specific SaaS Applications

application_rule_collection {
name = \"AllowSaaS\"
priority = 120
action = \"Allow\"

rule {
name = \"AllowOffice365\"
source_addresses = [\"10.1.0.0/16\"]

protocols {
type = \"Https\"
port = 443
}

destination_fqdn_tags = [\"Office365\"] # Simplified FQDN tag
}
}

Built-in FQDN Tags: WindowsUpdate, AzureBackup, AzureKubernetesService, MicrosoftActiveProtectionService

3. Inbound Web Traffic (DNAT)

nat_rule_collection {
name = \"PublicWebAccess\"
priority = 300
action = \"Dnat\"

rule {
name = \"HTTPSInbound\"
protocols = [\"TCP\"]
source_addresses = [\"*\"]
destination_address = azurerm_public_ip.firewall.ip_address
destination_ports = [\"443\"]
translated_address = \"10.1.1.10\" # Application Gateway internal IP
translated_port = \"443\"
}
}

Things to Avoid

Don't use 0.0.0.0/0 as source in allow rules (too permissive) ❌ Don't skip threat intelligence (free protection against known threats) ❌ Don't disable diagnostic logging (blind to attacks) ❌ Don't use Classic Rules (use Firewall Policy instead) ❌ Don't forget to update route tables after deploying firewall ❌ Don't expose SSH/RDP on standard ports (use DNAT with non-standard ports) ❌ Don't allow all outbound HTTPS without FQDN filtering ❌ Don't ignore Azure Firewall Premium for TLS inspection (if handling sensitive data)

Do use specific source/destination addresses ✅ Do enable threat intelligence (Alert or Deny) ✅ Do use IP Groups for easier management ✅ Do enable DNS proxy for FQDN filtering ✅ Do send logs to Log Analytics/Sentinel ✅ Do use FQDN tags (WindowsUpdate, Office365, etc.) ✅ Do implement defense-in-depth (Firewall + NSG) ✅ Do use Azure Firewall Premium for advanced threats (TLS inspection, IDPS)

Monitoring and Troubleshooting

Check Firewall Logs (KQL)

// Application rule hits
AzureDiagnostics
| where Category == \"AzureFirewallApplicationRule\"
| where TimeGenerated > ago(1h)
| parse msg_s with Protocol \" request from \" SourceIP \":\" SourcePort \" to \" FQDN \":\" DestinationPort \". Action: \" Action \".\"
| project TimeGenerated, Protocol, SourceIP, FQDN, DestinationPort, Action
| order by TimeGenerated desc

// Network rule hits
AzureDiagnostics
| where Category == \"AzureFirewallNetworkRule\"
| where TimeGenerated > ago(1h)
| parse msg_s with Protocol \" request from \" SourceIP \":\" SourcePort \" to \" DestinationIP \":\" DestinationPort \". Action: \" Action
| project TimeGenerated, Protocol, SourceIP, DestinationIP, DestinationPort, Action
| order by TimeGenerated desc

// Top blocked destinations
AzureDiagnostics
| where Category == \"AzureFirewallApplicationRule\"
| where msg_s contains \"Deny\"
| parse msg_s with * \" to \" FQDN \":\" *
| summarize DeniedRequests = count() by FQDN
| top 20 by DeniedRequests desc

Test Connectivity Through Firewall

# From a VM in spoke VNet
# Check if traffic is routing through firewall
traceroute google.com

# Expected: First hop should be firewall private IP (e.g., 10.0.1.4)

# Test specific connectivity
curl -v https://www.microsoft.com
# If blocked, check application rules

Common Errors

Error: "VM cannot reach internet"

  • Cause: Missing route table or incorrect next-hop IP
  • Fix: Verify route table has 0.0.0.0/0 → firewall private IP

Error: "DNAT not working"

  • Cause: NAT rule priority too low, or wrong destination IP
  • Fix: Ensure NAT rules are priority 100-199, use firewall's public IP as destination

Error: "FQDN filtering not working"

  • Cause: DNS proxy disabled
  • Fix: Enable DNS proxy in firewall policy

Cost Optimization

Pricing (Standard tier, East US):

  • Deployment: $1.25/hour (~$912/month)
  • Data processed: $0.016/GB

Example (100GB/day outbound):

  • Deployment: $912/month
  • Data: 100GB × 30 days × $0.016 = $48/month
  • Total: ~$960/month

How I manage costs:

  • Use Basic tier for dev/test ($0.50/hour)
  • Filter unnecessary traffic at NSG before reaching firewall
  • Use Azure Firewall Manager to share firewall across multiple subscriptions
  • Stop firewall in non-production environments outside business hours