Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for azurerm_firewall_policy: threat intelligence, DNS proxy, TLS inspection, IDPS, and rule collection groups wired up for hub-and-spoke. 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_policy" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-firewall-policy?ref=v1.0.0"
name = "..." # Name of the firewall policy (1-80 chars, validated).
resource_group_name = "..." # Resource group holding the policy.
location = "..." # Azure region.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure Firewall Policy (azurerm_firewall_policy) is the control-plane object that holds all the configuration and rules an Azure Firewall enforces — but, crucially, it is decoupled from the firewall instance itself. One policy can be associated with many firewalls across regions, and policies can be chained through a parent/child hierarchy so a platform team owns the base ruleset while application teams layer on their own rule collection groups. The policy carries the network/application/NAT rules (via azurerm_firewall_policy_rule_collection_group), threat intelligence mode, DNS proxy settings, IDPS (intrusion detection), and Premium-tier TLS inspection.
Wrapping it in a module matters because a Firewall Policy is rarely a single resource. In production you almost always pair it with at least one rule collection group, you want SKU/tier handling that is consistent (Standard vs Premium changes which features are legal), and you want the SNAT private-range and threat-intel allow/deny lists managed as code rather than clicked in the portal. The module gives you one var-driven surface so every spoke, every region, and every environment renders an identical, auditable policy — and a child policy can simply set base_policy_id to inherit the platform baseline.
When to use it
- You run a hub-and-spoke (or Virtual WAN secured hub) topology and want a single, versioned source of truth for egress/inter-spoke firewall rules.
- You need a parent/child policy hierarchy: a platform-owned base policy (deny-by-default, threat intel) inherited by per-landing-zone child policies that add app-specific allows.
- You are standardising Premium features — TLS inspection, IDPS signatures, and URL filtering — and want the SKU-gated settings expressed as code with validation so a Standard policy never silently drops Premium config.
- You want DNS proxy centralised so spokes resolve through the firewall and FQDN-based network rules actually work.
- You are subject to audit and must show that SNAT ranges, threat-intel mode, and rule changes flow through PR review, not portal edits.
Module structure
terraform-module-azure-firewall-policy/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_firewall_policy + rule collection group
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name + child_policies, rule group id
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
resource "azurerm_firewall_policy" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
sku = var.sku
# Chain to a platform base policy for a parent/child hierarchy.
base_policy_id = var.base_policy_id
# Threat intelligence: Off | Alert | Deny.
threat_intelligence_mode = var.threat_intelligence_mode
# SNAT: which destination ranges are treated as "private" (no SNAT).
private_ip_ranges = var.private_ip_ranges
auto_learn_private_ranges_enabled = var.auto_learn_private_ranges_enabled
dynamic "threat_intelligence_allowlist" {
for_each = (length(var.threat_intel_allowlist_ip_addresses) > 0 || length(var.threat_intel_allowlist_fqdns) > 0) ? [1] : []
content {
ip_addresses = var.threat_intel_allowlist_ip_addresses
fqdns = var.threat_intel_allowlist_fqdns
}
}
dynamic "dns" {
for_each = var.dns_proxy_enabled ? [1] : []
content {
proxy_enabled = true
servers = var.dns_servers
}
}
# IDPS — Premium only. Guarded by the SKU validation in variables.tf.
dynamic "intrusion_detection" {
for_each = (var.sku == "Premium" && var.intrusion_detection_mode != "Off") ? [1] : []
content {
mode = var.intrusion_detection_mode
private_ranges = var.idps_private_ranges
dynamic "signature_overrides" {
for_each = var.idps_signature_overrides
content {
id = signature_overrides.value.id
state = signature_overrides.value.state
}
}
dynamic "traffic_bypass" {
for_each = var.idps_traffic_bypass
content {
name = traffic_bypass.value.name
protocol = traffic_bypass.value.protocol
destination_ports = traffic_bypass.value.destination_ports
destination_addresses = traffic_bypass.value.destination_addresses
source_addresses = traffic_bypass.value.source_addresses
}
}
}
}
# TLS inspection — Premium only, needs an intermediate CA in Key Vault.
dynamic "tls_certificate" {
for_each = (var.sku == "Premium" && var.tls_key_vault_secret_id != null) ? [1] : []
content {
key_vault_secret_id = var.tls_key_vault_secret_id
name = var.tls_certificate_name
}
}
tags = var.tags
}
# A rule collection group owned by this policy. Network + application rules
# are the two most common collections in a hub egress policy.
resource "azurerm_firewall_policy_rule_collection_group" "this" {
count = var.rule_collection_group != null ? 1 : 0
name = var.rule_collection_group.name
firewall_policy_id = azurerm_firewall_policy.this.id
priority = var.rule_collection_group.priority
dynamic "network_rule_collection" {
for_each = var.rule_collection_group.network_rule_collections
content {
name = network_rule_collection.value.name
priority = network_rule_collection.value.priority
action = network_rule_collection.value.action
dynamic "rule" {
for_each = network_rule_collection.value.rules
content {
name = rule.value.name
protocols = rule.value.protocols
source_addresses = rule.value.source_addresses
destination_addresses = rule.value.destination_addresses
destination_ports = rule.value.destination_ports
}
}
}
}
dynamic "application_rule_collection" {
for_each = var.rule_collection_group.application_rule_collections
content {
name = application_rule_collection.value.name
priority = application_rule_collection.value.priority
action = application_rule_collection.value.action
dynamic "rule" {
for_each = application_rule_collection.value.rules
content {
name = rule.value.name
source_addresses = rule.value.source_addresses
destination_fqdns = rule.value.destination_fqdns
dynamic "protocols" {
for_each = rule.value.protocols
content {
type = protocols.value.type
port = protocols.value.port
}
}
}
}
}
}
}
variables.tf
variable "name" {
type = string
description = "Name of the firewall policy."
validation {
condition = can(regex("^[a-zA-Z0-9._-]{1,80}$", var.name))
error_message = "name must be 1-80 chars: letters, numbers, periods, underscores or hyphens."
}
}
variable "resource_group_name" {
type = string
description = "Resource group that will hold the firewall policy."
}
variable "location" {
type = string
description = "Azure region for the policy (e.g. centralindia)."
}
variable "sku" {
type = string
description = "Policy tier: Basic, Standard, or Premium. Must match the firewall SKU it is associated with."
default = "Standard"
validation {
condition = contains(["Basic", "Standard", "Premium"], var.sku)
error_message = "sku must be one of: Basic, Standard, Premium."
}
}
variable "base_policy_id" {
type = string
description = "Resource ID of a parent policy to inherit from. Null for a standalone/base policy."
default = null
}
variable "threat_intelligence_mode" {
type = string
description = "Microsoft threat intelligence mode: Off, Alert, or Deny."
default = "Alert"
validation {
condition = contains(["Off", "Alert", "Deny"], var.threat_intelligence_mode)
error_message = "threat_intelligence_mode must be Off, Alert, or Deny."
}
}
variable "threat_intel_allowlist_ip_addresses" {
type = list(string)
description = "IPs/CIDRs exempted from threat-intel filtering."
default = []
}
variable "threat_intel_allowlist_fqdns" {
type = list(string)
description = "FQDNs exempted from threat-intel filtering."
default = []
}
variable "private_ip_ranges" {
type = list(string)
description = "Destination ranges treated as private (traffic to these is not SNATed)."
default = ["IANAPrivateRanges"]
}
variable "auto_learn_private_ranges_enabled" {
type = bool
description = "Let the firewall auto-learn private ranges from associated route tables for SNAT."
default = false
}
variable "dns_proxy_enabled" {
type = bool
description = "Enable DNS proxy so spokes resolve through the firewall (required for FQDN network rules)."
default = false
}
variable "dns_servers" {
type = list(string)
description = "Custom upstream DNS servers used when DNS proxy is enabled. Empty = Azure-provided DNS."
default = []
}
variable "intrusion_detection_mode" {
type = string
description = "IDPS mode (Premium only): Off, Alert, or Deny."
default = "Off"
validation {
condition = contains(["Off", "Alert", "Deny"], var.intrusion_detection_mode)
error_message = "intrusion_detection_mode must be Off, Alert, or Deny."
}
}
variable "idps_private_ranges" {
type = list(string)
description = "Internal ranges IDPS should treat as private for inbound/outbound classification."
default = []
}
variable "idps_signature_overrides" {
type = list(object({
id = string
state = string # Off | Alert | Deny
}))
description = "Per-signature IDPS overrides (Premium)."
default = []
}
variable "idps_traffic_bypass" {
type = list(object({
name = string
protocol = string # TCP | UDP | ICMP | ANY
destination_ports = optional(list(string), [])
destination_addresses = optional(list(string), [])
source_addresses = optional(list(string), [])
}))
description = "Traffic flows that bypass IDPS inspection (Premium)."
default = []
}
variable "tls_key_vault_secret_id" {
type = string
description = "Key Vault secret ID of the intermediate CA cert for TLS inspection (Premium). Null disables TLS inspection."
default = null
}
variable "tls_certificate_name" {
type = string
description = "Friendly name for the TLS inspection certificate."
default = "tls-inspection-ca"
}
variable "rule_collection_group" {
type = object({
name = string
priority = number
network_rule_collections = optional(list(object({
name = string
priority = number
action = string # Allow | Deny
rules = list(object({
name = string
protocols = list(string) # TCP | UDP | ICMP | Any
source_addresses = list(string)
destination_addresses = list(string)
destination_ports = list(string)
}))
})), [])
application_rule_collections = optional(list(object({
name = string
priority = number
action = string # Allow | Deny
rules = list(object({
name = string
source_addresses = list(string)
destination_fqdns = list(string)
protocols = list(object({
type = string # Http | Https | Mssql
port = number
}))
}))
})), [])
})
description = "Optional rule collection group (network + application rules) owned by this policy. Null to manage rules elsewhere."
default = null
validation {
condition = var.rule_collection_group == null ? true : (var.rule_collection_group.priority >= 100 && var.rule_collection_group.priority <= 65000)
error_message = "rule_collection_group.priority must be between 100 and 65000."
}
}
variable "tags" {
type = map(string)
description = "Tags applied to the firewall policy."
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the firewall policy (associate this with azurerm_firewall.firewall_policy_id)."
value = azurerm_firewall_policy.this.id
}
output "name" {
description = "Name of the firewall policy."
value = azurerm_firewall_policy.this.name
}
output "child_policies" {
description = "Resource IDs of child policies that inherit from this policy."
value = azurerm_firewall_policy.this.child_policies
}
output "firewalls" {
description = "Resource IDs of firewalls currently associated with this policy."
value = azurerm_firewall_policy.this.firewalls
}
output "rule_collection_group_id" {
description = "Resource ID of the rule collection group, if one was created."
value = try(azurerm_firewall_policy_rule_collection_group.this[0].id, null)
}
How to use it
module "firewall_policy" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-firewall-policy?ref=v1.0.0"
name = "afwp-hub-prod-cin"
resource_group_name = azurerm_resource_group.hub.name
location = "centralindia"
sku = "Premium"
# Centralise DNS so FQDN network rules in spokes resolve correctly.
dns_proxy_enabled = true
dns_servers = ["10.0.0.4", "10.0.0.5"]
# Block known-bad destinations outright.
threat_intelligence_mode = "Deny"
threat_intel_allowlist_ip_addresses = ["20.40.0.0/16"] # internal partner range
# Premium IDPS in alert mode while we tune signatures.
intrusion_detection_mode = "Alert"
idps_private_ranges = ["10.0.0.0/8", "172.16.0.0/12"]
rule_collection_group = {
name = "rcg-egress-baseline"
priority = 300
network_rule_collections = [{
name = "ncoll-infra-allow"
priority = 400
action = "Allow"
rules = [{
name = "ntp-out"
protocols = ["UDP"]
source_addresses = ["10.0.0.0/8"]
destination_addresses = ["*"]
destination_ports = ["123"]
}]
}]
application_rule_collections = [{
name = "acoll-os-updates"
priority = 500
action = "Allow"
rules = [{
name = "windows-update"
source_addresses = ["10.0.0.0/8"]
destination_fqdns = ["*.update.microsoft.com", "*.windowsupdate.com"]
protocols = [{ type = "Https", port = 443 }]
}]
}]
}
tags = {
environment = "prod"
owner = "platform-network"
costcenter = "net-001"
}
}
# Downstream: associate the policy with the hub Azure Firewall using its output ID.
resource "azurerm_firewall" "hub" {
name = "afw-hub-prod-cin"
resource_group_name = azurerm_resource_group.hub.name
location = "centralindia"
sku_name = "AZFW_VNet"
sku_tier = "Premium"
firewall_policy_id = module.firewall_policy.id # <-- output consumed here
ip_configuration {
name = "ipconfig"
subnet_id = azurerm_subnet.firewall.id
public_ip_address_id = azurerm_public_ip.firewall.id
}
}
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_policy/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-policy?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/firewall_policy && 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 policy (1-80 chars, validated). |
| resource_group_name | string | — | Yes | Resource group holding the policy. |
| location | string | — | Yes | Azure region. |
| sku | string | “Standard” | No | Policy tier: Basic, Standard, or Premium. |
| base_policy_id | string | null | No | Parent policy ID for a parent/child hierarchy. |
| threat_intelligence_mode | string | “Alert” | No | Off, Alert, or Deny. |
| threat_intel_allowlist_ip_addresses | list(string) | [] | No | IPs/CIDRs exempt from threat-intel filtering. |
| threat_intel_allowlist_fqdns | list(string) | [] | No | FQDNs exempt from threat-intel filtering. |
| private_ip_ranges | list(string) | [“IANAPrivateRanges”] | No | Destination ranges not SNATed. |
| auto_learn_private_ranges_enabled | bool | false | No | Auto-learn private SNAT ranges from route tables. |
| dns_proxy_enabled | bool | false | No | Enable DNS proxy (needed for FQDN network rules). |
| dns_servers | list(string) | [] | No | Upstream DNS servers when proxy is enabled. |
| intrusion_detection_mode | string | “Off” | No | IDPS mode (Premium): Off, Alert, Deny. |
| idps_private_ranges | list(string) | [] | No | Internal ranges for IDPS classification. |
| idps_signature_overrides | list(object) | [] | No | Per-signature IDPS state overrides. |
| idps_traffic_bypass | list(object) | [] | No | Flows that bypass IDPS inspection. |
| tls_key_vault_secret_id | string | null | No | Key Vault secret ID of intermediate CA for TLS inspection (Premium). |
| tls_certificate_name | string | “tls-inspection-ca” | No | Friendly name for the TLS cert. |
| rule_collection_group | object | null | No | Optional network + application rule collection group. |
| tags | map(string) | {} | No | Tags applied to the policy. |
Outputs
| Name | Description |
|---|---|
| id | Resource ID of the firewall policy; feed into azurerm_firewall.firewall_policy_id. |
| name | Name of the firewall policy. |
| child_policies | Resource IDs of child policies inheriting from this policy. |
| firewalls | Resource IDs of firewalls currently associated with this policy. |
| rule_collection_group_id | Resource ID of the rule collection group, or null if none was created. |
Enterprise scenario
A financial-services group runs a secured hub in Central India and a DR hub in South India, both fronted by Azure Firewall Premium. The platform team deploys one base policy from this module (threat intel = Deny, DNS proxy on, IDPS in Alert) and exposes its id as the base_policy_id for six per-landing-zone child policies — payments, retail, internal, etc. Each application team consumes the module to render only its own rule_collection_group, so a new egress FQDN for the payments PCI zone is a reviewed pull request against one child policy, while the deny-by-default baseline and IDPS signatures stay centrally owned and identical across both regions.
Best practices
- Decouple policy from firewall and pin SKUs together. Keep
skuon the policy equal to the firewall’ssku_tier(Premium policy ↔ Premium firewall); the module’s validation stops Premium-only blocks (IDPS, TLS) from leaking into a Standard policy, which Azure would otherwise reject at apply. - Use a parent/child hierarchy for governance, not copy-paste. Put deny-by-default, threat intel, and DNS proxy in a base policy and inherit it via
base_policy_id; child policies should only add app-specific rule collection groups. Remember inherited base rules always evaluate before child rules. - Turn on DNS proxy before relying on FQDN network rules. FQDN-based network rules silently fail to match unless the firewall is the resolver — set
dns_proxy_enabled = trueand point spokes’ VNet DNS at the firewall private IP. - Stage IDPS and threat intel in Alert first. Run
intrusion_detection_mode = "Alert"andthreat_intelligence_mode = "Alert", watch the logs for false positives, addidps_signature_overrides/ allowlists, then promote to Deny — flipping straight to Deny in prod will block legitimate traffic. - Control cost via tier and rule hygiene. Premium roughly doubles the firewall’s hourly rate and adds TLS/IDPS compute; only enable
tls_key_vault_secret_idand IDPS where compliance demands it, and prune unused rule collections since every policy still bills through its attached firewall. - Name and prioritise deterministically. Use a stable convention (
afwp-<role>-<env>-<region>, e.g.afwp-hub-prod-cin) and leave gaps between rule-collection-group and collection priorities (300, 400, 500…) so you can insert rules later without renumbering.