IaC Azure

Terraform Module: Azure Web Application Firewall Policy — one OWASP-tuned ruleset, reusable across every front door and gateway

Quick take — Build a reusable Terraform module for the azurerm_web_application_firewall_policy resource: managed OWASP/Bot rule sets, custom rate-limit and geo-match rules, exclusions, and Prevention/Detection modes for Azure Front Door and App Gateway. 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 "waf_policy" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-waf-policy?ref=v1.0.0"

  name                = "..."  # Policy name; alphanumeric only (1-128 chars) for Front …
  resource_group_name = "..."  # Resource group for the policy.
  location            = "..."  # Azure region for the policy.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An Azure Web Application Firewall (WAF) Policy is the rule container that sits in front of your HTTP(S) endpoints and inspects every request before it reaches the backend. The azurerm_web_application_firewall_policy resource defines three things in one object: the managed rule sets (Microsoft’s curated OWASP Core Rule Set plus the Microsoft Bot Manager set), your own custom rules (rate limiting, geo blocking, IP allow/deny, header matches), and the policy-level settings (Prevention vs Detection mode, request body inspection, file upload limits). That same policy resource is consumed by Azure Front Door (via firewall_policy_link_id on a security policy) and by Application Gateway (via the firewall_policy_id argument), so it is genuinely a shared, cross-product primitive.

Hand-writing one of these is deceptively painful. A production policy carries dozens of rule overrides, ordered custom rules with non-clashing priorities, managed rule exclusions to stop false positives, and a strict body-size configuration — and you typically need an identical policy on every environment and every edge. Wrapping it in a module lets you express the security posture once (CRS version, default mode, rate-limit thresholds, blocked countries) and stamp it out for dev, staging, and prod with only the mode and thresholds varying. It also keeps the brittle, easy-to-misconfigure parts — rule-set versions, exclusion selectors, custom-rule priorities — under code review instead of in the portal.

When to use it

Reach for a hand-rolled resource only for a throwaway proof-of-concept; the moment a policy is shared or reproduced, the module pays for itself.

Module structure

terraform-module-azure-waf-policy/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}
# main.tf
locals {
  # Front Door requires the policy name to be alphanumeric only; App Gateway is
  # more permissive. We surface the raw name and let validation guard it.
  policy_name = var.name
}

resource "azurerm_web_application_firewall_policy" "this" {
  name                = local.policy_name
  resource_group_name = var.resource_group_name
  location            = var.location
  tags                = var.tags

  policy_settings {
    enabled                          = var.policy_enabled
    mode                             = var.mode
    request_body_check               = var.request_body_check
    file_upload_limit_in_mb          = var.file_upload_limit_in_mb
    max_request_body_size_in_kb      = var.max_request_body_size_in_kb
    request_body_inspect_limit_in_kb = var.request_body_inspect_limit_in_kb
  }

  managed_rules {
    # Exclusions apply across all managed rule sets (e.g. silence a noisy header).
    dynamic "exclusion" {
      for_each = var.managed_rule_exclusions
      content {
        match_variable          = exclusion.value.match_variable
        selector                = exclusion.value.selector
        selector_match_operator = exclusion.value.selector_match_operator
      }
    }

    dynamic "managed_rule_set" {
      for_each = var.managed_rule_sets
      content {
        type    = managed_rule_set.value.type
        version = managed_rule_set.value.version

        dynamic "rule_group_override" {
          for_each = managed_rule_set.value.rule_group_overrides
          content {
            rule_group_name = rule_group_override.value.rule_group_name

            dynamic "rule" {
              for_each = rule_group_override.value.rules
              content {
                id      = rule.value.id
                enabled = rule.value.enabled
                action  = rule.value.action
              }
            }
          }
        }
      }
    }
  }

  # Custom rules are evaluated in priority order (lowest number first) before
  # the managed rule sets — ideal for rate limiting, geo and IP controls.
  dynamic "custom_rules" {
    for_each = var.custom_rules
    content {
      name                           = custom_rules.value.name
      priority                       = custom_rules.value.priority
      rule_type                      = custom_rules.value.rule_type
      action                         = custom_rules.value.action
      enabled                        = custom_rules.value.enabled
      rate_limit_duration            = custom_rules.value.rate_limit_duration
      rate_limit_threshold           = custom_rules.value.rate_limit_threshold
      group_rate_limit_by            = custom_rules.value.group_rate_limit_by

      dynamic "match_conditions" {
        for_each = custom_rules.value.match_conditions
        content {
          operator           = match_conditions.value.operator
          negation_condition = match_conditions.value.negation_condition
          match_values       = match_conditions.value.match_values
          transforms         = match_conditions.value.transforms

          dynamic "match_variables" {
            for_each = match_conditions.value.match_variables
            content {
              variable_name = match_variables.value.variable_name
              selector      = match_variables.value.selector
            }
          }
        }
      }
    }
  }
}
# variables.tf
variable "name" {
  description = "Name of the WAF policy. For Azure Front Door use must be alphanumeric only (1-128 chars)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9]{1,128}$", var.name))
    error_message = "name must be 1-128 alphanumeric characters (Front Door rejects hyphens/underscores)."
  }
}

variable "resource_group_name" {
  description = "Resource group in which to create the WAF policy."
  type        = string
}

variable "location" {
  description = "Azure region for the policy (e.g. 'eastus'). Use 'global' style RG location for Front Door policies."
  type        = string
}

variable "tags" {
  description = "Tags to apply to the WAF policy."
  type        = map(string)
  default     = {}
}

variable "policy_enabled" {
  description = "Whether the policy is enabled."
  type        = bool
  default     = true
}

variable "mode" {
  description = "Policy enforcement mode: 'Prevention' (block) or 'Detection' (log only)."
  type        = string
  default     = "Prevention"

  validation {
    condition     = contains(["Prevention", "Detection"], var.mode)
    error_message = "mode must be either 'Prevention' or 'Detection'."
  }
}

variable "request_body_check" {
  description = "Inspect the request body against rules. Disabling weakens protection; keep true."
  type        = bool
  default     = true
}

variable "file_upload_limit_in_mb" {
  description = "Maximum file upload size inspected, in MB (Front Door max 4000; App Gateway max 750)."
  type        = number
  default     = 100

  validation {
    condition     = var.file_upload_limit_in_mb >= 1 && var.file_upload_limit_in_mb <= 4000
    error_message = "file_upload_limit_in_mb must be between 1 and 4000."
  }
}

variable "max_request_body_size_in_kb" {
  description = "Maximum request body size in KB before the request is rejected."
  type        = number
  default     = 128

  validation {
    condition     = var.max_request_body_size_in_kb >= 8 && var.max_request_body_size_in_kb <= 2000
    error_message = "max_request_body_size_in_kb must be between 8 and 2000."
  }
}

variable "request_body_inspect_limit_in_kb" {
  description = "Maximum body size that is fully inspected, in KB. Set 0 to inspect with no limit (Front Door)."
  type        = number
  default     = 128
}

variable "managed_rule_sets" {
  description = <<-EOT
    Managed rule sets to attach. Typical production set is OWASP CRS plus the
    Microsoft Bot Manager rule set. Each rule set can carry per-rule overrides.
  EOT
  type = list(object({
    type    = string
    version = string
    rule_group_overrides = optional(list(object({
      rule_group_name = string
      rules = list(object({
        id      = string
        enabled = optional(bool, true)
        action  = optional(string, "Block")
      }))
    })), [])
  }))
  default = [
    {
      type    = "Microsoft_DefaultRuleSet"
      version = "2.1"
    },
    {
      type    = "Microsoft_BotManagerRuleSet"
      version = "1.0"
    }
  ]

  validation {
    condition     = length(var.managed_rule_sets) > 0
    error_message = "At least one managed rule set is required for a meaningful WAF policy."
  }
}

variable "managed_rule_exclusions" {
  description = "Global exclusions to suppress known false positives across managed rule sets."
  type = list(object({
    match_variable          = string
    selector                = string
    selector_match_operator = string
  }))
  default = []
}

variable "custom_rules" {
  description = <<-EOT
    Custom rules evaluated before managed rules, in ascending priority order.
    Use rule_type 'MatchRule' for geo/IP/header controls and 'RateLimitRule'
    for throttling. rate_limit_* and group_rate_limit_by only apply to RateLimitRule.
  EOT
  type = list(object({
    name                 = string
    priority             = number
    rule_type            = optional(string, "MatchRule")
    action               = string
    enabled              = optional(bool, true)
    rate_limit_duration  = optional(string)
    rate_limit_threshold = optional(number)
    group_rate_limit_by  = optional(string)
    match_conditions = list(object({
      operator           = string
      negation_condition = optional(bool, false)
      match_values       = list(string)
      transforms         = optional(list(string), [])
      match_variables = list(object({
        variable_name = string
        selector      = optional(string)
      }))
    }))
  }))
  default = []

  validation {
    condition = alltrue([
      for r in var.custom_rules : contains(["Allow", "Block", "Log", "JSChallenge"], r.action)
    ])
    error_message = "Each custom rule action must be one of Allow, Block, Log, or JSChallenge."
  }

  validation {
    condition = length(distinct([for r in var.custom_rules : r.priority])) == length(var.custom_rules)
    error_message = "custom_rules priorities must be unique."
  }
}
# outputs.tf
output "id" {
  description = "Resource ID of the WAF policy. Pass to Front Door (firewall_policy_link_id) or App Gateway (firewall_policy_id)."
  value       = azurerm_web_application_firewall_policy.this.id
}

output "name" {
  description = "Name of the WAF policy."
  value       = azurerm_web_application_firewall_policy.this.name
}

output "resource_group_name" {
  description = "Resource group containing the WAF policy."
  value       = azurerm_web_application_firewall_policy.this.resource_group_name
}

output "mode" {
  description = "Enforcement mode the policy was created with (Prevention or Detection)."
  value       = azurerm_web_application_firewall_policy.this.policy_settings[0].mode
}

output "http_listener_ids" {
  description = "App Gateway HTTP listener IDs associated with this policy (empty until linked)."
  value       = azurerm_web_application_firewall_policy.this.http_listener_ids
}

How to use it

module "web_application_firewall_policy" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-waf-policy?ref=v1.0.0"

  name                = "kvprodfdwaf"
  resource_group_name = azurerm_resource_group.edge.name
  location            = azurerm_resource_group.edge.location
  mode                = "Prevention"

  max_request_body_size_in_kb = 256
  file_upload_limit_in_mb     = 250

  managed_rule_sets = [
    {
      type    = "Microsoft_DefaultRuleSet"
      version = "2.1"
      rule_group_overrides = [
        {
          # Relax a known-noisy SQLi rule to Log instead of Block.
          rule_group_name = "SQLI"
          rules = [
            { id = "942440", action = "Log" }
          ]
        }
      ]
    },
    {
      type    = "Microsoft_BotManagerRuleSet"
      version = "1.0"
    }
  ]

  managed_rule_exclusions = [
    {
      match_variable          = "RequestHeaderNames"
      selector                = "x-company-secret"
      selector_match_operator = "Equals"
    }
  ]

  custom_rules = [
    {
      name      = "BlockNonAllowedGeos"
      priority  = 10
      rule_type = "MatchRule"
      action    = "Block"
      match_conditions = [
        {
          operator           = "GeoMatch"
          negation_condition = true
          match_values       = ["IN", "US", "GB"]
          match_variables    = [{ variable_name = "RemoteAddr" }]
        }
      ]
    },
    {
      name                 = "RateLimitPerIP"
      priority             = 20
      rule_type            = "RateLimitRule"
      action               = "Block"
      rate_limit_duration  = "OneMin"
      rate_limit_threshold = 100
      group_rate_limit_by  = "ClientAddr"
      match_conditions = [
        {
          operator        = "IPMatch"
          match_values    = ["0.0.0.0/0"]
          match_variables = [{ variable_name = "RemoteAddr" }]
        }
      ]
    }
  ]

  tags = {
    environment = "prod"
    owner       = "platform-security"
  }
}

# Downstream: attach the policy to an Azure Front Door endpoint via a security policy.
resource "azurerm_cdn_frontdoor_security_policy" "waf" {
  name                     = "kv-prod-waf-link"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.this.id

  security_policies {
    firewall {
      cdn_frontdoor_firewall_policy_id = module.web_application_firewall_policy.id

      association {
        domain {
          cdn_frontdoor_domain_id = azurerm_cdn_frontdoor_endpoint.this.id
        }
        patterns_to_match = ["/*"]
      }
    }
  }
}

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/waf_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-waf-policy?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/waf_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 Policy name; alphanumeric only (1-128 chars) for Front Door compatibility.
resource_group_name string Yes Resource group for the policy.
location string Yes Azure region for the policy.
tags map(string) {} No Tags applied to the policy.
policy_enabled bool true No Whether the policy is enabled.
mode string "Prevention" No Prevention (block) or Detection (log only).
request_body_check bool true No Inspect request bodies against the rules.
file_upload_limit_in_mb number 100 No Max inspected upload size in MB (1-4000).
max_request_body_size_in_kb number 128 No Max request body size in KB before rejection (8-2000).
request_body_inspect_limit_in_kb number 128 No Max body size fully inspected, in KB (0 = unlimited on Front Door).
managed_rule_sets list(object) OWASP CRS 2.1 + Bot Manager 1.0 No Managed rule sets with optional per-rule overrides.
managed_rule_exclusions list(object) [] No Global exclusions to suppress false positives.
custom_rules list(object) [] No Custom match/rate-limit rules; unique priorities, evaluated before managed rules.

Outputs

Name Description
id Resource ID of the WAF policy (link target for Front Door / App Gateway).
name Name of the WAF policy.
resource_group_name Resource group containing the policy.
mode Enforcement mode the policy was created with.
http_listener_ids App Gateway HTTP listener IDs associated with the policy.

Enterprise scenario

A fintech runs a customer-facing API behind Azure Front Door Premium across three environments. The platform-security team publishes this module pinned at v1.0.0 and the product squads consume it: dev and staging instantiate it with mode = "Detection" so new releases surface WAF hits in Log Analytics without breaking testers, while prod runs mode = "Prevention" with a 100-request/minute per-IP rate limit and a geo-block restricting traffic to India, the US, and the UK. When a quarterly OWASP CRS bump lands, security updates the version default in one place, opens a single PR, and every environment inherits the new rule set on the next apply — the audit trail satisfies the PCI-DSS evidence request automatically.

Best practices

TerraformAzureWeb Application Firewall 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