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
| Feature | Azure Firewall | Network Security Group (NSG) |
|---|---|---|
| OSI Layer | L3-L7 (Network to Application) | L3-L4 (Network & Transport) |
| Filtering | FQDN, IP, port, protocol | IP, port, protocol only |
| Centralized | Hub-based, protects entire VNet | Distributed per subnet/NIC |
| Threat Intelligence | ✅ Built-in | ❌ Not available |
| Logging | Diagnostic logs, Azure Monitor | NSG Flow Logs |
| Cost | $$$ Hourly + data processed | $ Free (just storage for logs) |
| Use Case | Hub firewall, application filtering | Micro-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:
- Outbound: Spoke → Hub (via route table) → Azure Firewall → Internet
- Inbound: Internet → Azure Firewall (DNAT) → Spoke
- Spoke-to-Spoke: Spoke 1 → Hub → Azure Firewall → Spoke 2
Rule Processing Order
Azure Firewall processes rules in this sequence:
- NAT Rules (DNAT for inbound)
- Network Rules (Layer 3/4 filtering)
- 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