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
- You front workloads with Azure Front Door Premium or Application Gateway v2 and need a consistent, version-controlled WAF posture across them.
- You want Detection mode in lower environments and Prevention mode in prod from a single source of truth, flipped by one variable.
- You need rate limiting (e.g. 100 requests/minute per client IP) or geo-blocking as first-class, reviewable custom rules rather than portal clicks.
- You are chasing PCI-DSS / SOC 2 evidence and need the OWASP CRS version, managed rule overrides, and exclusions captured in Git history.
- You operate many tenants or many apps and want each to inherit the same baseline ruleset with per-app exclusions layered on top.
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 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/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
- Run Detection before Prevention. Deploy every new policy or CRS version in
Detectionmode first, watchAzureDiagnostics/ WAF logs for a release cycle, build exclusions for false positives, then flipmode = "Prevention". Blocking blind almost always breaks a legitimate path. - Pin the managed rule set version explicitly. Never rely on “latest”. Set
version = "2.1"forMicrosoft_DefaultRuleSetso a provider upgrade can’t silently change which rules fire; bump it deliberately through a reviewed PR. - Prefer surgical exclusions over disabling rules. When a rule causes false positives, add a
managed_rule_exclusionscoped to the exactmatch_variable/selector, or override that single rule toLog. Disabling an entire rule group (e.g. all of SQLI) is a common and dangerous over-correction. - Keep custom-rule priorities sparse and unique. Number them 10, 20, 30 so you can insert rules later without renumbering; the module enforces uniqueness, and lower numbers win — put IP allow-lists ahead of geo-blocks ahead of rate limits.
- Right-size body and upload limits to the workload. Defaults of 128 KB body / 100 MB upload suit most APIs; raising them widens the inspection surface and cost, while setting them too low rejects legitimate multipart uploads. Tune per app rather than globally.
- Name policies for the product, not the environment alone. Use a stable alphanumeric scheme like
kvprodfdwafso Front Door accepts the name, and tag withenvironmentandownerso cost and ownership are queryable across many policies.