Quick take — Deploy Azure Firewall with Terraform and azurerm ~> 4.0: a reusable module wiring up the firewall, its Firewall Policy, a static public IP, and optional forced-tunnelling, with SKU and zone validation baked in. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "firewall" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-firewall?ref=v1.0.0"
name = "..." # Name of the firewall and prefix for child resources (1-…
resource_group_name = "..." # Resource group for the firewall, policy, and public IP(…
location = "..." # Azure region for all resources.
subnet_id = "..." # ID of the subnet named exactly `AzureFirewallSubnet` (m…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Firewall is a managed, stateful, cloud-native network firewall-as-a-service that sits at the centre of a hub-and-spoke topology and inspects north-south and east-west traffic. Unlike a Network Security Group — which is a stateless-ish ACL pinned to a subnet or NIC — Azure Firewall gives you fully qualified domain name (FQDN) filtering, threat intelligence-based filtering, TLS inspection (Premium), IDPS, and a single highly-available choke point with a static, well-known SNAT egress IP. It runs as a fleet of instances behind a Microsoft-managed load balancer, scales automatically, and lives in a dedicated AzureFirewallSubnet.
The reason to wrap it in a module is that a correct Azure Firewall is never one resource. In production you almost always need: the firewall itself (azurerm_firewall), a Firewall Policy (azurerm_firewall_policy) so rules are version-controlled and inheritable across regions, at least one static Standard public IP for a deterministic egress address, and an ip_configuration block bound precisely to a subnet named AzureFirewallSubnet. Get any of those wrong — a Basic SKU IP, a misnamed subnet, a missing policy association — and the apply fails or, worse, silently degrades. This module encodes those constraints once (including SKU/zone validation), so every spoke network in your estate gets an identical, opinionated firewall.
When to use it
- You run a hub-and-spoke (or Virtual WAN secured-hub style) topology and need a central egress and inspection point for all spoke traffic.
- You need FQDN-based outbound filtering (e.g. allow
*.ubuntu.com,*.azure.com, deny everything else) that NSGs cannot express. - You want a fixed, allow-listable SNAT IP so partners and SaaS providers can whitelist your outbound traffic.
- You are consolidating per-spoke NVAs (third-party virtual appliances) onto a managed, auto-scaling, zone-redundant service to cut operational toil.
- You need forced tunnelling to send all internet-bound traffic back on-premises via ExpressRoute for inspection by an existing perimeter.
Skip it (or use just NSGs / Application Gateway WAF) when you only need L4 segmentation inside a single VNet, or when the workload is a public web app fronted by Front Door where egress control is not a requirement — Azure Firewall has a non-trivial hourly + per-GB cost and is overkill for those.
Module structure
terraform-module-azure-firewall/
├── versions.tf # provider + Terraform version pins
├── main.tf # public IP, firewall policy, firewall, optional mgmt IP
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name, private IP, public IP, policy id
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
# Static public IP for deterministic SNAT egress.
# Azure Firewall mandates a Standard SKU, Static allocation public IP.
resource "azurerm_public_ip" "this" {
name = "${var.name}-pip"
resource_group_name = var.resource_group_name
location = var.location
allocation_method = "Static"
sku = "Standard"
zones = var.availability_zones
tags = var.tags
}
# Optional second public IP, required only for forced tunnelling
# (the management plane needs its own IP + AzureFirewallManagementSubnet).
resource "azurerm_public_ip" "management" {
count = var.enable_forced_tunneling ? 1 : 0
name = "${var.name}-mgmt-pip"
resource_group_name = var.resource_group_name
location = var.location
allocation_method = "Static"
sku = "Standard"
zones = var.availability_zones
tags = var.tags
}
# Firewall Policy: rules live here, decoupled from the firewall instance
# so they can be inherited and centrally managed.
resource "azurerm_firewall_policy" "this" {
name = "${var.name}-policy"
resource_group_name = var.resource_group_name
location = var.location
sku = var.sku_tier
threat_intelligence_mode = var.threat_intel_mode
dns {
proxy_enabled = var.dns_proxy_enabled
servers = var.dns_servers
}
tags = var.tags
}
resource "azurerm_firewall" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
sku_name = "AZFW_VNet"
sku_tier = var.sku_tier
firewall_policy_id = azurerm_firewall_policy.this.id
zones = var.availability_zones
threat_intel_mode = null # governed by the policy when one is attached
ip_configuration {
name = "ipconfig"
subnet_id = var.subnet_id
public_ip_address_id = azurerm_public_ip.this.id
}
# Management IP config is only valid (and required) for forced tunnelling.
dynamic "management_ip_configuration" {
for_each = var.enable_forced_tunneling ? [1] : []
content {
name = "mgmtipconfig"
subnet_id = var.management_subnet_id
public_ip_address_id = azurerm_public_ip.management[0].id
}
}
tags = var.tags
}
variables.tf
variable "name" {
description = "Name of the Azure Firewall and prefix for its child resources."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-_]{0,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 1-80 chars, start alphanumeric, and contain only letters, digits, hyphens, and underscores."
}
}
variable "resource_group_name" {
description = "Resource group that will hold the firewall, policy, and public IPs."
type = string
}
variable "location" {
description = "Azure region for all resources (e.g. centralindia)."
type = string
}
variable "subnet_id" {
description = "Resource ID of the subnet named exactly 'AzureFirewallSubnet' (min /26)."
type = string
validation {
condition = can(regex("/subnets/AzureFirewallSubnet$", var.subnet_id))
error_message = "subnet_id must reference a subnet named exactly 'AzureFirewallSubnet'."
}
}
variable "sku_tier" {
description = "Firewall SKU tier: Basic, Standard, or Premium."
type = string
default = "Standard"
validation {
condition = contains(["Basic", "Standard", "Premium"], var.sku_tier)
error_message = "sku_tier must be one of: Basic, Standard, Premium."
}
}
variable "availability_zones" {
description = "Availability zones to spread the firewall and its IPs across. Empty list = no zone redundancy."
type = list(string)
default = ["1", "2", "3"]
validation {
condition = alltrue([for z in var.availability_zones : contains(["1", "2", "3"], z)])
error_message = "availability_zones may only contain the values \"1\", \"2\", and \"3\"."
}
}
variable "threat_intel_mode" {
description = "Threat intelligence-based filtering mode on the policy: Off, Alert, or Deny."
type = string
default = "Alert"
validation {
condition = contains(["Off", "Alert", "Deny"], var.threat_intel_mode)
error_message = "threat_intel_mode must be one of: Off, Alert, Deny."
}
}
variable "dns_proxy_enabled" {
description = "Enable the firewall as a DNS proxy (required for FQDN filtering in network rules)."
type = bool
default = true
}
variable "dns_servers" {
description = "Custom upstream DNS servers for the firewall's DNS proxy. Empty = Azure-provided DNS."
type = list(string)
default = []
}
variable "enable_forced_tunneling" {
description = "Provision a management IP + management subnet config so all traffic can be forced-tunnelled on-premises."
type = bool
default = false
}
variable "management_subnet_id" {
description = "Resource ID of the subnet named 'AzureFirewallManagementSubnet'. Required when enable_forced_tunneling is true."
type = string
default = null
validation {
condition = var.management_subnet_id == null || can(regex("/subnets/AzureFirewallManagementSubnet$", var.management_subnet_id))
error_message = "management_subnet_id, when set, must reference a subnet named exactly 'AzureFirewallManagementSubnet'."
}
}
variable "tags" {
description = "Tags applied to every resource created by the module."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Azure Firewall."
value = azurerm_firewall.this.id
}
output "name" {
description = "Name of the Azure Firewall."
value = azurerm_firewall.this.name
}
output "private_ip_address" {
description = "Private IP of the firewall — use this as the next hop in spoke route tables."
value = azurerm_firewall.this.ip_configuration[0].private_ip_address
}
output "public_ip_address" {
description = "Static public (SNAT egress) IP of the firewall — allow-list this with partners."
value = azurerm_public_ip.this.ip_address
}
output "firewall_policy_id" {
description = "Resource ID of the attached Firewall Policy — attach rule collection groups to this."
value = azurerm_firewall_policy.this.id
}
How to use it
module "azure_firewall" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-firewall?ref=v1.0.0"
name = "afw-hub-prod-cin"
resource_group_name = azurerm_resource_group.hub.name
location = "centralindia"
# Must be the subnet literally named AzureFirewallSubnet (min /26).
subnet_id = azurerm_subnet.firewall.id
sku_tier = "Standard"
availability_zones = ["1", "2", "3"]
threat_intel_mode = "Deny"
dns_proxy_enabled = true
tags = {
environment = "prod"
owner = "platform-network"
costcenter = "net-001"
}
}
# Downstream: force all spoke egress through the firewall's private IP
# by pointing the spoke route table's default route at it.
resource "azurerm_route_table" "spoke" {
name = "rt-spoke-prod-cin"
resource_group_name = azurerm_resource_group.hub.name
location = "centralindia"
}
resource "azurerm_route" "default_to_firewall" {
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 = module.azure_firewall.private_ip_address
}
# Attach centrally-managed rules to the policy this module created.
resource "azurerm_firewall_policy_rule_collection_group" "egress" {
name = "egress-baseline"
firewall_policy_id = module.azure_firewall.firewall_policy_id
priority = 500
application_rule_collection {
name = "allowed-fqdns"
priority = 500
action = "Allow"
rule {
name = "os-and-azure"
source_addresses = ["10.0.0.0/8"]
destination_fqdns = [
"*.ubuntu.com",
"*.azure.com",
"*.microsoft.com",
]
protocols {
type = "Https"
port = 443
}
}
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/firewall/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-firewall?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
subnet_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/firewall && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the firewall and prefix for child resources (1-80 chars, validated). |
resource_group_name |
string |
— | Yes | Resource group for the firewall, policy, and public IP(s). |
location |
string |
— | Yes | Azure region for all resources. |
subnet_id |
string |
— | Yes | ID of the subnet named exactly AzureFirewallSubnet (min /26), validated. |
sku_tier |
string |
"Standard" |
No | Firewall tier: Basic, Standard, or Premium. |
availability_zones |
list(string) |
["1","2","3"] |
No | Zones for the firewall and IPs; [] disables zone redundancy. |
threat_intel_mode |
string |
"Alert" |
No | Threat-intel filtering on the policy: Off, Alert, or Deny. |
dns_proxy_enabled |
bool |
true |
No | Enable DNS proxy (needed for FQDN filtering in network rules). |
dns_servers |
list(string) |
[] |
No | Custom upstream DNS servers; empty uses Azure DNS. |
enable_forced_tunneling |
bool |
false |
No | Provision management IP + config for forced tunnelling on-premises. |
management_subnet_id |
string |
null |
No | ID of AzureFirewallManagementSubnet; required when forced tunnelling is on. |
tags |
map(string) |
{} |
No | Tags applied to every created resource. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Azure Firewall. |
name |
Name of the Azure Firewall. |
private_ip_address |
Firewall private IP — use as the next hop in spoke route tables. |
public_ip_address |
Static SNAT egress IP — allow-list this with external partners. |
firewall_policy_id |
ID of the attached Firewall Policy — attach rule collection groups here. |
Enterprise scenario
A pan-India insurer runs a hub-and-spoke estate with thirty workload spokes across two regions. The platform team consumes this module once per region to stand up an identical zone-redundant Standard firewall, then attaches a shared baseline Firewall Policy rule collection group that permits only approved FQDNs (PAN-aadhaar validation APIs, Azure management endpoints, OS patch repos) and runs threat-intel in Deny mode. Every spoke’s user-defined route forces 0.0.0.0/0 through the firewall’s private_ip_address output, while the partner KYC SaaS allow-lists the firewall’s single static public_ip_address — giving auditors one egress chokepoint and one well-known IP to reason about across the whole organisation.
Best practices
- Pin the SNAT IP and treat it as an asset. The module emits a single
Static/Standardpublic IP; document it, allow-list it with partners, and never let it churn. If you outgrow ~2,500 SNAT ports per public IP under heavy egress, add IPs deliberately rather than letting Azure rotate them. - Put rules in the Firewall Policy, not the firewall. This module deliberately creates and attaches an
azurerm_firewall_policyso rule collection groups are version-controlled, diff-able in PRs, and inheritable across regions — never manage rules in the portal where Terraform will fight them on the next apply. - Deploy zone-redundant in any region that supports it. Keep
availability_zones = ["1","2","3"]; the SLA jumps to 99.99% and the public IP is spread across zones too. Only drop zones for cost in non-prod or zone-less regions. - Right-size the SKU and watch the bill.
Basiccaps throughput and lacks DNS proxy/IDPS;Premiumadds TLS inspection and IDPS at a higher hourly rate. Most hubs wantStandard. Remember you pay an hourly deployment charge plus per-GB processed — consolidate spokes onto one firewall rather than running several. - Enable DNS proxy when you use FQDN network rules. Keep
dns_proxy_enabled = trueso the firewall resolves and caches names consistently; without it, FQDN filtering in network (L4) rules is unreliable. - Name for the topology and tag for ownership. Use a region/role naming scheme like
afw-hub-prod-cin, and passtagswithenvironment,owner, andcostcenterso the firewall’s non-trivial spend is attributable in Cost Management.