IaC Azure

Terraform Module: Azure Firewall Policy — centralised rule governance for your hub firewall

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

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 configlive/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 configlive/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

TerraformAzureFirewall PolicyModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading