IaC Azure

Terraform Module: Azure Azure Firewall — a policy-driven, zone-redundant hub firewall in one block

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

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

TerraformAzureAzure FirewallModuleIaC
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