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
- You front an ALB, API Gateway, or AppSync API and want the AWS Managed Rules baseline (Common Rule Set, Known Bad Inputs, SQLi, IP reputation) without re-typing the nested
managed_rule_group_statementboilerplate for every workload. - You need a rate-based rule to blunt credential-stuffing or volumetric L7 floods (e.g. block any IP exceeding 2,000 requests per 5 minutes) and want it consistently shaped across services.
- You protect a CloudFront distribution and therefore need a
CLOUDFRONT-scope ACL created inus-east-1, ideally from the same module you use for regional resources. - You want IP allow/block lists (partner egress IPs always allowed; abusive ranges always blocked) referenced by ARN, with the right precedence relative to managed rules.
- You want safe rollout: ship a new managed rule group in
countmode first, watch the CloudWatchBlockedRequestsit would have generated, then flip it to block — without rewriting HCL.
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 config — live/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 config — live/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
- Roll new rule groups out in
countmode first. A managed rule set that looks harmless can block legitimate traffic (a base64 payload that trips an SQLi signature). Ship withcount_override = true, watch CloudWatchCountedRequests/sampled requests, then enforce — this module makes that a one-line flip, not a rewrite. - Mind the 5000 WCU capacity budget. Every rule consumes Web ACL Capacity Units; the AWS Common Rule Set alone is ~700 WCU. Surface the
capacityoutput in CI and alarm before you hit the hard cap, because an over-budget ACL simply fails to update. - Order matters: allow-lists first, then blocks, then managed rules. WAF evaluates by ascending
priorityand short-circuits on a terminating action; this module reserves priority bands so a partner allow-rule always wins over a broad managed block, and adding rules to one band never reshuffles another. - Put the ACL in the right region/scope.
CLOUDFRONTWeb ACLs and their IP sets must live inus-east-1;REGIONALACLs must be in the same region as the ALB/API Gateway they protect. A scope/region mismatch is the most common WAFv2 deployment failure. - Enable logging and redact secrets. Stream to a Firehose/log group named
aws-waf-logs-*for forensics and false-positive triage, and useredacted_header_namesto keepauthorization/cookievalues out of log storage and out of scope for compliance. - Tag for cost and ownership. WAFv2 bills per Web ACL per month, per rule, and per million requests; consistent
Environment/Teamtags let Cost Explorer attribute spend and reveal orphaned ACLs still billing against deleted services.