IaC AWS

Terraform Module: AWS WAFv2 — managed-rule protection for ALB, API Gateway, and CloudFront in one block

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_wafv2_web_acl with AWS managed rule groups, rate-based rules, IP allow/block sets, logging, and CloudWatch metrics — REGIONAL or CLOUDFRONT scope. 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 "aws" {
  region = "us-east-1"
}

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

  name = "..."  # Web ACL name; base for rule names and the `Name` tag (1…
}

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

What this module is

AWS WAF (v2) is a layer-7 web application firewall that inspects HTTP(S) requests before they reach your origin. The central resource, aws_wafv2_web_acl, is an ordered list of rules — each one either allows, blocks, counts, or CAPTCHA/challenges a request based on its match conditions — plus a default_action for everything that falls through. A Web ACL is associated with one or more protected resources: an Application Load Balancer, an API Gateway stage, an AppSync API, a Cognito user pool, or — when scope = "CLOUDFRONT" — a CloudFront distribution.

The thing that makes WAFv2 awkward to hand-roll is its deeply nested, order-sensitive shape. Every rule needs a priority, an action or an override_action (managed rule groups use the latter), and a mandatory visibility_config block, and the managed-rule-group statements live three levels deep. Get the priorities or the action/override semantics wrong and you either block all traffic or silently protect nothing. Wrapping it in a module turns that into typed inputs: you pass a list of AWS managed rule groups, an optional rate limit, and IP set references, and the module assembles the aws_wafv2_web_acl with correct, contiguous priorities, a fail-safe count-by-default option for new rules, CloudWatch metrics on every rule, and (optionally) a aws_wafv2_web_acl_logging_configuration wired to a Kinesis Firehose or log group — guardrails you cannot encode in a console click-through.

When to use it

If you only need network-layer (L3/L4) filtering, a security group or Network ACL is the right tool; WAFv2 is for HTTP-aware rules (paths, headers, bodies, SQLi/XSS, geo, rate).

Module structure

terraform-module-aws-waf/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # aws_wafv2_web_acl + logging + association
├── variables.tf     # scope, managed groups, rate rule, IP sets (validated)
└── outputs.tf       # id, arn, capacity, name

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Block-by-default Web ACLs invert the action: anything not explicitly
  # allowed is blocked. Most edge ACLs allow-by-default and let rules block.
  default_action_block = var.default_action == "block"

  # Reserve a deterministic priority band per rule category so adding a rule
  # to one category never reshuffles the others.
  #   IP allow  : 0-9    (must win, evaluated first)
  #   IP block  : 10-19
  #   rate rule : 20
  #   managed   : 100+
  ip_allow_base   = 0
  ip_block_base   = 10
  rate_priority   = 20
  managed_base    = 100
}

resource "aws_wafv2_web_acl" "this" {
  name        = var.name
  description = var.description
  scope       = var.scope

  default_action {
    dynamic "allow" {
      for_each = local.default_action_block ? [] : [1]
      content {}
    }
    dynamic "block" {
      for_each = local.default_action_block ? [1] : []
      content {}
    }
  }

  # ---------------------------------------------------------------------
  # 1. IP allow-list rules — highest precedence, short-circuit to allow.
  # ---------------------------------------------------------------------
  dynamic "rule" {
    for_each = { for idx, arn in var.ip_allow_set_arns : idx => arn }
    content {
      name     = "${var.name}-ip-allow-${rule.key}"
      priority = local.ip_allow_base + rule.key

      action {
        allow {}
      }

      statement {
        ip_set_reference_statement {
          arn = rule.value
        }
      }

      visibility_config {
        sampled_requests_enabled   = true
        cloudwatch_metrics_enabled = true
        metric_name                = "${var.metric_prefix}-ip-allow-${rule.key}"
      }
    }
  }

  # ---------------------------------------------------------------------
  # 2. IP block-list rules — always block matching source IPs.
  # ---------------------------------------------------------------------
  dynamic "rule" {
    for_each = { for idx, arn in var.ip_block_set_arns : idx => arn }
    content {
      name     = "${var.name}-ip-block-${rule.key}"
      priority = local.ip_block_base + rule.key

      action {
        block {}
      }

      statement {
        ip_set_reference_statement {
          arn = rule.value
        }
      }

      visibility_config {
        sampled_requests_enabled   = true
        cloudwatch_metrics_enabled = true
        metric_name                = "${var.metric_prefix}-ip-block-${rule.key}"
      }
    }
  }

  # ---------------------------------------------------------------------
  # 3. Rate-based rule — block any client IP over the request threshold.
  # ---------------------------------------------------------------------
  dynamic "rule" {
    for_each = var.rate_limit == null ? [] : [var.rate_limit]
    content {
      name     = "${var.name}-rate-limit"
      priority = local.rate_priority

      action {
        block {}
      }

      statement {
        rate_based_statement {
          limit              = rule.value
          aggregate_key_type = var.rate_aggregate_key_type
        }
      }

      visibility_config {
        sampled_requests_enabled   = true
        cloudwatch_metrics_enabled = true
        metric_name                = "${var.metric_prefix}-rate-limit"
      }
    }
  }

  # ---------------------------------------------------------------------
  # 4. AWS managed rule groups — the protection baseline. Each can be set
  #    to count (override) for safe rollout, or none to actually enforce.
  # ---------------------------------------------------------------------
  dynamic "rule" {
    for_each = { for idx, g in var.managed_rule_groups : idx => g }
    content {
      name     = rule.value.name
      priority = local.managed_base + rule.key

      # override_action governs managed groups (not action). "none" lets the
      # group's own rule actions apply; "count" forces every match to count.
      override_action {
        dynamic "none" {
          for_each = rule.value.count_override ? [] : [1]
          content {}
        }
        dynamic "count" {
          for_each = rule.value.count_override ? [1] : []
          content {}
        }
      }

      statement {
        managed_rule_group_statement {
          name        = rule.value.name
          vendor_name = rule.value.vendor_name
          version     = rule.value.version

          dynamic "rule_action_override" {
            for_each = rule.value.rule_action_overrides
            content {
              name = rule_action_override.value.name
              action_to_use {
                dynamic "count" {
                  for_each = rule_action_override.value.action == "count" ? [1] : []
                  content {}
                }
                dynamic "allow" {
                  for_each = rule_action_override.value.action == "allow" ? [1] : []
                  content {}
                }
                dynamic "block" {
                  for_each = rule_action_override.value.action == "block" ? [1] : []
                  content {}
                }
              }
            }
          }
        }
      }

      visibility_config {
        sampled_requests_enabled   = true
        cloudwatch_metrics_enabled = true
        metric_name                = "${var.metric_prefix}-${rule.value.name}"
      }
    }
  }

  visibility_config {
    sampled_requests_enabled   = true
    cloudwatch_metrics_enabled = true
    metric_name                = "${var.metric_prefix}-default"
  }

  tags = merge(var.tags, { Name = var.name })
}

# Optional: associate the ACL with a REGIONAL resource (ALB / API Gateway
# stage / AppSync). CloudFront associations are set on the distribution itself.
resource "aws_wafv2_web_acl_association" "this" {
  for_each = var.scope == "REGIONAL" ? toset(var.associated_resource_arns) : toset([])

  resource_arn = each.value
  web_acl_arn  = aws_wafv2_web_acl.this.arn
}

# Optional: stream full request logs to Firehose / a CloudWatch log group /
# an S3 bucket (whatever the supplied log_destination ARN points to).
resource "aws_wafv2_web_acl_logging_configuration" "this" {
  count = var.log_destination_arn == null ? 0 : 1

  resource_arn            = aws_wafv2_web_acl.this.arn
  log_destination_configs = [var.log_destination_arn]

  dynamic "redacted_fields" {
    for_each = var.redacted_header_names
    content {
      single_header {
        name = redacted_fields.value
      }
    }
  }
}

variables.tf

variable "name" {
  description = "Name of the Web ACL; also used as the base for per-rule names and tags."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9-_]{1,128}$", var.name))
    error_message = "name must be 1-128 chars of letters, digits, hyphens or underscores."
  }
}

variable "description" {
  description = "Human-readable description of the Web ACL's purpose."
  type        = string
  default     = "Managed by Terraform"

  validation {
    condition     = length(var.description) >= 1 && length(var.description) <= 256
    error_message = "description must be between 1 and 256 characters."
  }
}

variable "scope" {
  description = "REGIONAL (ALB, API Gateway, AppSync, Cognito) or CLOUDFRONT. CLOUDFRONT ACLs MUST be created in us-east-1."
  type        = string
  default     = "REGIONAL"

  validation {
    condition     = contains(["REGIONAL", "CLOUDFRONT"], var.scope)
    error_message = "scope must be either \"REGIONAL\" or \"CLOUDFRONT\"."
  }
}

variable "default_action" {
  description = "Action for requests that match no rule: \"allow\" (typical edge ACL) or \"block\" (allow-list-only ACL)."
  type        = string
  default     = "allow"

  validation {
    condition     = contains(["allow", "block"], var.default_action)
    error_message = "default_action must be \"allow\" or \"block\"."
  }
}

variable "metric_prefix" {
  description = "Prefix for CloudWatch metric_name on every rule. Letters, digits, hyphens, underscores only."
  type        = string
  default     = "waf"

  validation {
    condition     = can(regex("^[a-zA-Z0-9-_]{1,200}$", var.metric_prefix))
    error_message = "metric_prefix must be 1-200 chars of letters, digits, hyphens or underscores."
  }
}

variable "managed_rule_groups" {
  description = <<-EOT
    Ordered list of AWS (or marketplace) managed rule groups to attach.
    `count_override = true` puts the whole group in COUNT mode for safe
    rollout. `rule_action_overrides` lets you down-tune noisy individual
    rules inside an enforcing group (e.g. count a false-positive rule).
    Example:
    [
      { name = "AWSManagedRulesCommonRuleSet",        vendor_name = "AWS" },
      { name = "AWSManagedRulesKnownBadInputsRuleSet", vendor_name = "AWS" },
      { name = "AWSManagedRulesSQLiRuleSet",           vendor_name = "AWS", count_override = true },
    ]
  EOT

  type = list(object({
    name           = string
    vendor_name    = optional(string, "AWS")
    version        = optional(string)        # null = always-latest
    count_override = optional(bool, false)
    rule_action_overrides = optional(list(object({
      name   = string
      action = string                        # count | allow | block
    })), [])
  }))
  default = []

  validation {
    condition = alltrue([
      for g in var.managed_rule_groups : alltrue([
        for o in g.rule_action_overrides : contains(["count", "allow", "block"], o.action)
      ])
    ])
    error_message = "Each rule_action_overrides action must be one of count, allow, block."
  }
}

variable "rate_limit" {
  description = "If set, adds a rate-based rule that blocks any aggregated key exceeding this many requests per 5 minutes (10-2,000,000,000). Null disables it."
  type        = number
  default     = null

  validation {
    condition     = var.rate_limit == null || (var.rate_limit >= 10 && var.rate_limit <= 2000000000)
    error_message = "rate_limit must be null or between 10 and 2000000000 (requests per 5-minute window)."
  }
}

variable "rate_aggregate_key_type" {
  description = "How the rate-based rule groups requests: IP, FORWARDED_IP (behind a proxy/CDN), or CONSTANT (global)."
  type        = string
  default     = "IP"

  validation {
    condition     = contains(["IP", "FORWARDED_IP", "CONSTANT"], var.rate_aggregate_key_type)
    error_message = "rate_aggregate_key_type must be IP, FORWARDED_IP, or CONSTANT."
  }
}

variable "ip_allow_set_arns" {
  description = "ARNs of aws_wafv2_ip_set resources whose IPs are always ALLOWED (evaluated before all other rules). Max 10."
  type        = list(string)
  default     = []

  validation {
    condition     = length(var.ip_allow_set_arns) <= 10
    error_message = "ip_allow_set_arns supports at most 10 IP sets (priority band 0-9)."
  }
}

variable "ip_block_set_arns" {
  description = "ARNs of aws_wafv2_ip_set resources whose IPs are always BLOCKED. Max 10."
  type        = list(string)
  default     = []

  validation {
    condition     = length(var.ip_block_set_arns) <= 10
    error_message = "ip_block_set_arns supports at most 10 IP sets (priority band 10-19)."
  }
}

variable "associated_resource_arns" {
  description = "REGIONAL only: ARNs of ALBs / API Gateway stages / AppSync APIs to associate with this ACL. Ignored when scope = CLOUDFRONT."
  type        = list(string)
  default     = []
}

variable "log_destination_arn" {
  description = "ARN of a Kinesis Firehose, CloudWatch log group, or S3 bucket for full request logging. Null disables logging. (Firehose/log-group names must start with aws-waf-logs-.)"
  type        = string
  default     = null
}

variable "redacted_header_names" {
  description = "Header names to redact from WAF logs (e.g. authorization, cookie) to keep secrets out of log storage."
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Tags applied to the Web ACL."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "The ID of the WAFv2 Web ACL."
  value       = aws_wafv2_web_acl.this.id
}

output "arn" {
  description = "The ARN of the Web ACL — set this as web_acl_id on a CloudFront distribution or pass to an association."
  value       = aws_wafv2_web_acl.this.arn
}

output "name" {
  description = "The name of the Web ACL."
  value       = aws_wafv2_web_acl.this.name
}

output "capacity" {
  description = "The Web ACL Capacity Units (WCU) consumed by all rules; the hard limit per ACL is 5000."
  value       = aws_wafv2_web_acl.this.capacity
}

output "scope" {
  description = "The scope the ACL was created in (REGIONAL or CLOUDFRONT)."
  value       = aws_wafv2_web_acl.this.scope
}

output "logging_enabled" {
  description = "Whether a logging configuration was attached to this Web ACL."
  value       = var.log_destination_arn != null
}

How to use it

This example creates a REGIONAL Web ACL fronting an ALB: it always allows a partner IP set, blocks an abuse IP set, rate-limits per source IP, and applies three AWS managed rule groups — with the SQLi set shipped in count mode first for a safe rollout.

# IP sets the ACL references (could also live in their own module).
resource "aws_wafv2_ip_set" "partners" {
  name               = "partners-allow"
  scope              = "REGIONAL"
  ip_address_version = "IPV4"
  addresses          = ["198.51.100.0/24", "203.0.113.10/32"]
}

resource "aws_wafv2_ip_set" "abuse" {
  name               = "known-abuse"
  scope              = "REGIONAL"
  ip_address_version = "IPV4"
  addresses          = ["192.0.2.0/24"]
}

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

  name          = "orders-edge"
  description   = "Edge protection for the orders ALB"
  scope         = "REGIONAL"
  metric_prefix = "ordersedge"

  ip_allow_set_arns = [aws_wafv2_ip_set.partners.arn]
  ip_block_set_arns = [aws_wafv2_ip_set.abuse.arn]

  rate_limit              = 2000          # requests / 5 min per IP
  rate_aggregate_key_type = "IP"

  managed_rule_groups = [
    { name = "AWSManagedRulesCommonRuleSet", vendor_name = "AWS" },
    { name = "AWSManagedRulesKnownBadInputsRuleSet", vendor_name = "AWS" },
    {
      name           = "AWSManagedRulesSQLiRuleSet"
      vendor_name    = "AWS"
      count_override = true                # observe first, enforce later
    },
  ]

  associated_resource_arns = [aws_lb.orders.arn]
  log_destination_arn      = aws_kinesis_firehose_delivery_stream.waf.arn
  redacted_header_names    = ["authorization", "cookie"]

  tags = {
    Environment = "prod"
    Team        = "payments"
  }
}

# Downstream: alarm on the ACL's blocked requests using its name, and surface
# the consumed capacity so a noisy ruleset never silently hits the 5000 WCU cap.
resource "aws_cloudwatch_metric_alarm" "waf_blocks" {
  alarm_name          = "orders-edge-blocked-spike"
  namespace           = "AWS/WAFV2"
  metric_name         = "BlockedRequests"
  statistic           = "Sum"
  period              = 300
  evaluation_periods  = 1
  threshold           = 1000
  comparison_operator = "GreaterThanThreshold"

  dimensions = {
    WebACL = module.wafv2.name
    Region = "ap-south-1"
    Rule   = "ALL"
  }
}

output "waf_capacity_used" {
  value = module.wafv2.capacity
}

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 = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/waf/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name = "..."
}

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

cd live/prod/waf && 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 Web ACL name; base for rule names and the Name tag (1–128 chars).
description string "Managed by Terraform" No Purpose of the ACL (1–256 chars).
scope string "REGIONAL" No REGIONAL or CLOUDFRONT; CLOUDFRONT must be created in us-east-1.
default_action string "allow" No Action for unmatched requests: allow or block.
metric_prefix string "waf" No Prefix for the CloudWatch metric_name on every rule.
managed_rule_groups list(object) [] No Ordered AWS/marketplace managed rule groups; supports count_override and per-rule rule_action_overrides.
rate_limit number null No Requests per 5-min window per key before blocking (10–2,000,000,000); null disables.
rate_aggregate_key_type string "IP" No Rate aggregation: IP, FORWARDED_IP, or CONSTANT.
ip_allow_set_arns list(string) [] No IP-set ARNs always allowed (highest precedence); max 10.
ip_block_set_arns list(string) [] No IP-set ARNs always blocked; max 10.
associated_resource_arns list(string) [] No REGIONAL resource ARNs (ALB/API GW/AppSync) to associate; ignored for CLOUDFRONT.
log_destination_arn string null No Firehose/log-group/S3 ARN for request logging; null disables.
redacted_header_names list(string) [] No Header names to redact from WAF logs (e.g. authorization).
tags map(string) {} No Tags applied to the Web ACL.

Outputs

Name Description
id The Web ACL ID.
arn The Web ACL ARN — set as web_acl_id on a CloudFront distribution or pass to an association.
name The Web ACL name (use as the WebACL CloudWatch dimension).
capacity WCU consumed by all rules; the per-ACL limit is 5000.
scope The scope the ACL was created in (REGIONAL or CLOUDFRONT).
logging_enabled true when a logging configuration was attached.

Enterprise scenario

A retail platform runs dozens of public APIs behind regional ALBs in ap-south-1 plus a global storefront on CloudFront. The platform team publishes this module once at v1.0.0; every API team instantiates it with the same Common Rule Set, Known Bad Inputs, and IP-reputation baseline and a 2,000 req/5-min rate limit, while the storefront stack calls it with scope = "CLOUDFRONT" from a us-east-1 provider alias. When AWS ships a new managed-rule version, teams add it with count_override = true, watch the BlockedRequests it would generate in CloudWatch for a week, then drop the override — turning a historically scary change into a reviewed, metric-driven pull request with a full audit trail.

Best practices

TerraformAWSWAFv2ModuleIaC
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