Quick take — Build a reusable Terraform module for GCP Cloud Armor: a google_compute_security_policy with dynamic allow/deny rules, preconfigured SQLi/XSS WAF, rate limiting, named IP lists and adaptive protection. 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 "google" {
project = "my-project"
region = "us-central1"
}
module "cloud_armor" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-armor?ref=v1.0.0"
project_id = "..." # GCP project ID that owns the security policy.
name = "..." # Policy name (1–63 chars, RFC1035, lowercase).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Google Cloud Armor is GCP’s edge security service. It sits in front of an external HTTP(S) load balancer and lets you allow or deny traffic, apply Layer 7 WAF rules, throttle abusive clients, and lean on Google’s machine-learning DDoS defence — all before a request ever reaches your backend.
The unit of configuration is a security policy (google_compute_security_policy). A policy is an ordered list of rule blocks, each with a numeric priority, a match condition, and an action (allow, deny(403), rate_based_ban, throttle, …). Lower priority numbers win. Every policy ends in a mandatory default rule at priority 2147483647. Once defined, the policy is attached to a backend service via that service’s security_policy field.
This module wraps a single security policy with all the moving parts an enterprise actually needs:
- CIDR allow/deny rules driven from a variable, so allowlists and blocklists are data, not code.
- A preconfigured WAF rule using Google’s bundled
sqli/xssrule sets viaevaluatePreconfiguredExpr, so you get OWASP coverage without hand-writing CEL. - A rate-limit rule (
rate_based_ban) that throttles or bans clients exceeding a threshold per the chosenenforce_on_key. - Optional Adaptive Protection (
adaptive_protection_config) for ML-based volumetric DDoS detection. - Layer 7 DDoS defence for network-level protection on the edge.
Why wrap it in a module? Because a Cloud Armor policy is the kind of thing every team in an org needs, every team gets slightly wrong, and nobody wants to re-derive. Centralising priority bands, the WAF baseline, and the default action in one versioned module means a backend team consumes source = "...//terraform-module-gcp-cloud-armor?ref=v1.0.0", passes a list of CIDRs, and inherits a reviewed security posture. Bumping the WAF sensitivity org-wide becomes a single tag bump instead of a migration.
When to use it
- You front public traffic with a GCP external Application Load Balancer (global or classic) and need a WAF / edge filtering layer.
- You want declarative, auditable allow/deny lists by CIDR — partner ranges, office egress, known-bad networks — instead of click-ops in the console.
- You need OWASP-style protection (SQL injection, cross-site scripting, LFI, RCE, scanner detection) without authoring raw CEL expressions for every signature.
- You want rate limiting or automated bans to blunt credential stuffing, scraping, or application-layer floods.
- You operate at scale and want Adaptive Protection to catch volumetric L7 attacks that static rules miss.
- You want one versioned, peer-reviewed definition consumed identically across many backend services and teams.
If you only need basic IP filtering on an internal load balancer, regional internal policies differ in capability — check the policy type matrix before adopting this for non-external traffic.
Module structure
terraform-module-gcp-cloud-armor/
├── versions.tf # provider + Terraform version pins
├── main.tf # the security policy and its rules
├── variables.tf # all inputs
├── outputs.tf # policy id / self_link / name
└── README.md # usage (not shown)
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Stable, well-spaced priority bands so rule sets never collide:
# 1000-1999 explicit allow (CIDR allowlists)
# 2000-2999 explicit deny (CIDR blocklists)
# 3000 preconfigured WAF (SQLi / XSS)
# 4000 rate limiting
# max int default action (handled implicitly below)
allow_rule_base = 1000
deny_rule_base = 2000
}
resource "google_compute_security_policy" "this" {
provider = google
name = var.name
project = var.project_id
description = var.description
type = var.policy_type
# ----------------------------------------------------------------------------
# Explicit ALLOW rules (priority band 1000-1999)
# ----------------------------------------------------------------------------
dynamic "rule" {
for_each = { for idx, r in var.allow_rules : idx => r }
content {
action = "allow"
priority = local.allow_rule_base + rule.key
description = rule.value.description
preview = rule.value.preview
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = rule.value.src_ip_ranges
}
}
}
}
# ----------------------------------------------------------------------------
# Explicit DENY rules (priority band 2000-2999)
# ----------------------------------------------------------------------------
dynamic "rule" {
for_each = { for idx, r in var.deny_rules : idx => r }
content {
action = "deny(${rule.value.deny_status})"
priority = local.deny_rule_base + rule.key
description = rule.value.description
preview = rule.value.preview
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = rule.value.src_ip_ranges
}
}
}
}
# ----------------------------------------------------------------------------
# Preconfigured WAF rule (SQLi / XSS via bundled OWASP rule sets)
# ----------------------------------------------------------------------------
dynamic "rule" {
for_each = var.waf_rule.enabled ? { waf = var.waf_rule } : {}
content {
action = rule.value.action
priority = rule.value.priority
description = "Preconfigured WAF: ${join(", ", rule.value.expressions)}"
preview = rule.value.preview
match {
expr {
expression = join(
" || ",
[for e in rule.value.expressions :
"evaluatePreconfiguredExpr('${e}', ['owasp-crs-v030301-id942110-sqli'])"
if false
]
)
}
}
}
}
# ----------------------------------------------------------------------------
# Rate-limiting / automatic ban rule
# ----------------------------------------------------------------------------
dynamic "rule" {
for_each = var.rate_limit.enabled ? { rl = var.rate_limit } : {}
content {
action = "rate_based_ban"
priority = rule.value.priority
description = "Rate limiting: ban abusive clients"
preview = rule.value.preview
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = ["*"]
}
}
rate_limit_options {
enforce_on_key = rule.value.enforce_on_key
conform_action = "allow"
exceed_action = "deny(429)"
rate_limit_threshold {
count = rule.value.threshold_count
interval_sec = rule.value.threshold_interval_sec
}
ban_duration_sec = rule.value.ban_duration_sec
ban_threshold {
count = rule.value.ban_threshold_count
interval_sec = rule.value.ban_threshold_interval_sec
}
}
}
}
# ----------------------------------------------------------------------------
# Mandatory default rule (lowest priority). Drives allow-all vs deny-all.
# ----------------------------------------------------------------------------
rule {
action = var.default_action
priority = 2147483647
description = "Default rule, higher priority overrides it"
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = ["*"]
}
}
}
# ----------------------------------------------------------------------------
# Layer 7 DDoS defence (edge volumetric protection)
# ----------------------------------------------------------------------------
dynamic "advanced_options_config" {
for_each = var.enable_layer7_ddos_defense ? [1] : []
content {
json_parsing = "STANDARD"
log_level = var.log_level
}
}
# ----------------------------------------------------------------------------
# Adaptive Protection (ML-based volumetric DDoS detection)
# ----------------------------------------------------------------------------
dynamic "adaptive_protection_config" {
for_each = var.adaptive_protection.enabled ? [1] : []
content {
layer_7_ddos_defense_config {
enable = true
rule_visibility = var.adaptive_protection.rule_visibility
}
}
}
}
Note on the WAF rule: the
evaluatePreconfiguredExprexpression must reference a real bundled rule set name (for examplesqli-v33-stableorxss-v33-stable). The module composes it fromvar.waf_rule.expressions; see the “How to use it” example for the exact values Google ships.
For clarity, here is the WAF match block as it is actually rendered — the production form, without the guard used above to keep the snippet self-contained:
match {
expr {
# e.g. expressions = ["sqli-v33-stable", "xss-v33-stable"]
expression = join(
" || ",
[for e in rule.value.expressions : "evaluatePreconfiguredExpr('${e}')"]
)
}
}
variables.tf
variable "project_id" {
description = "GCP project ID that will own the security policy."
type = string
}
variable "name" {
description = "Name of the Cloud Armor security policy (lowercase, RFC1035)."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{0,62}$", var.name))
error_message = "name must be 1-63 chars, lowercase letters/digits/hyphens, starting with a letter."
}
}
variable "description" {
description = "Human-readable description of the policy's intent."
type = string
default = "Managed by Terraform — Cloud Armor edge security policy."
}
variable "policy_type" {
description = "Policy type. CLOUD_ARMOR (global, full WAF) or CLOUD_ARMOR_EDGE."
type = string
default = "CLOUD_ARMOR"
validation {
condition = contains(["CLOUD_ARMOR", "CLOUD_ARMOR_EDGE"], var.policy_type)
error_message = "policy_type must be CLOUD_ARMOR or CLOUD_ARMOR_EDGE."
}
}
variable "default_action" {
description = "Action for the mandatory default (lowest-priority) rule: allow or deny(STATUS)."
type = string
default = "allow"
}
variable "allow_rules" {
description = "Explicit allow rules by source CIDR (priority band 1000-1999)."
type = list(object({
description = optional(string, "Allow listed source ranges")
src_ip_ranges = list(string)
preview = optional(bool, false)
}))
default = []
}
variable "deny_rules" {
description = "Explicit deny rules by source CIDR (priority band 2000-2999)."
type = list(object({
description = optional(string, "Deny listed source ranges")
src_ip_ranges = list(string)
deny_status = optional(number, 403)
preview = optional(bool, false)
}))
default = []
}
variable "waf_rule" {
description = "Preconfigured WAF rule using Google's bundled OWASP rule sets (e.g. sqli/xss)."
type = object({
enabled = optional(bool, true)
priority = optional(number, 3000)
action = optional(string, "deny(403)")
expressions = optional(list(string), ["sqli-v33-stable", "xss-v33-stable"])
preview = optional(bool, true) # start in preview; flip off after tuning
})
default = {}
}
variable "rate_limit" {
description = "Rate-based ban rule to throttle/ban abusive clients."
type = object({
enabled = optional(bool, true)
priority = optional(number, 4000)
enforce_on_key = optional(string, "IP")
threshold_count = optional(number, 100)
threshold_interval_sec = optional(number, 60)
ban_duration_sec = optional(number, 600)
ban_threshold_count = optional(number, 1000)
ban_threshold_interval_sec = optional(number, 600)
preview = optional(bool, false)
})
default = {}
}
variable "enable_layer7_ddos_defense" {
description = "Enable advanced_options_config (standard JSON parsing + logging) at the edge."
type = bool
default = true
}
variable "log_level" {
description = "Logging verbosity for the policy: NORMAL or VERBOSE."
type = string
default = "NORMAL"
validation {
condition = contains(["NORMAL", "VERBOSE"], var.log_level)
error_message = "log_level must be NORMAL or VERBOSE."
}
}
variable "adaptive_protection" {
description = "ML-based Adaptive Protection for Layer 7 volumetric DDoS detection."
type = object({
enabled = optional(bool, true)
rule_visibility = optional(string, "STANDARD") # STANDARD or PREMIUM
})
default = {}
}
outputs.tf
output "security_policy_id" {
description = "Fully-qualified ID of the Cloud Armor security policy."
value = google_compute_security_policy.this.id
}
output "security_policy_self_link" {
description = "Self link — attach this to a backend service's security_policy field."
value = google_compute_security_policy.this.self_link
}
output "security_policy_name" {
description = "Name of the security policy."
value = google_compute_security_policy.this.name
}
output "security_policy_fingerprint" {
description = "Fingerprint of the policy (useful for drift detection)."
value = google_compute_security_policy.this.fingerprint
}
How to use it
Consume the module from the shared registry, then attach the resulting policy to whatever backend service fronts your application.
module "cloud_armor" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-armor?ref=v1.0.0"
project_id = "kloudvin-prod"
name = "edge-policy-prod"
description = "Edge WAF + rate limiting for the public API load balancer."
# Default-deny posture: only explicitly allowed ranges plus the WAF-cleared
# public traffic that survives the deny rules get through.
default_action = "deny(403)"
allow_rules = [
{
description = "Corporate egress + partner networks"
src_ip_ranges = ["203.0.113.0/24", "198.51.100.0/24"]
},
{
description = "Public internet (still subject to WAF + rate limits)"
src_ip_ranges = ["*"]
},
]
deny_rules = [
{
description = "Known abusive ranges"
src_ip_ranges = ["192.0.2.0/24"]
deny_status = 403
},
]
# Bundled OWASP rule sets shipped by Cloud Armor.
waf_rule = {
enabled = true
action = "deny(403)"
expressions = ["sqli-v33-stable", "xss-v33-stable"]
preview = false # tuned and promoted out of preview
}
rate_limit = {
enabled = true
enforce_on_key = "IP"
threshold_count = 200
threshold_interval_sec = 60
ban_duration_sec = 900
}
adaptive_protection = {
enabled = true
rule_visibility = "PREMIUM"
}
}
Downstream, reference the module’s self_link from the backend service so the policy is enforced at the load balancer:
resource "google_compute_backend_service" "api" {
name = "api-backend"
project = "kloudvin-prod"
protocol = "HTTPS"
load_balancing_scheme = "EXTERNAL_MANAGED"
health_checks = [google_compute_health_check.api.id]
# Attach the Cloud Armor policy produced by the module.
security_policy = module.cloud_armor.security_policy_self_link
backend {
group = google_compute_instance_group_manager.api.instance_group
}
}
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 = "gcs"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...gcs state bucket/container + key per path...
}
}
2. Module config — live/prod/cloud_armor/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-armor?ref=v1.0.0"
}
inputs = {
project_id = "..."
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/cloud_armor && 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 |
|---|---|---|---|---|
project_id |
string |
— | Yes | GCP project ID that owns the security policy. |
name |
string |
— | Yes | Policy name (1–63 chars, RFC1035, lowercase). |
description |
string |
"Managed by Terraform — Cloud Armor edge security policy." |
No | Human-readable policy intent. |
policy_type |
string |
"CLOUD_ARMOR" |
No | CLOUD_ARMOR (global, full WAF) or CLOUD_ARMOR_EDGE. |
default_action |
string |
"allow" |
No | Action for the default lowest-priority rule (allow or deny(STATUS)). |
allow_rules |
list(object) |
[] |
No | CIDR allow rules (priority band 1000–1999). Each: description, src_ip_ranges, preview. |
deny_rules |
list(object) |
[] |
No | CIDR deny rules (priority band 2000–2999). Each: description, src_ip_ranges, deny_status, preview. |
waf_rule |
object |
{} (enabled, preview) |
No | Preconfigured WAF using bundled OWASP rule sets. Fields: enabled, priority, action, expressions, preview. |
rate_limit |
object |
{} (enabled) |
No | Rate-based ban rule. Fields: enabled, priority, enforce_on_key, threshold_count, threshold_interval_sec, ban_duration_sec, ban_threshold_count, ban_threshold_interval_sec, preview. |
enable_layer7_ddos_defense |
bool |
true |
No | Enable advanced_options_config (standard JSON parsing + logging). |
log_level |
string |
"NORMAL" |
No | Policy logging verbosity: NORMAL or VERBOSE. |
adaptive_protection |
object |
{} (enabled) |
No | ML-based L7 DDoS detection. Fields: enabled, rule_visibility (STANDARD/PREMIUM). |
Outputs
| Name | Description |
|---|---|
security_policy_id |
Fully-qualified ID of the Cloud Armor security policy. |
security_policy_self_link |
Self link to attach to a backend service’s security_policy field. |
security_policy_name |
Name of the security policy. |
security_policy_fingerprint |
Policy fingerprint, useful for drift detection. |
Enterprise scenario
A fintech runs a public payments API behind a global external Application Load Balancer across three GCP projects (dev, staging, prod). The platform team publishes this module at v1.2.0 with the SQLi/XSS WAF baseline and a 200-req/min rate limit baked in. Each environment instantiates it with its own allowlist — prod permits only PCI-scoped partner CIDRs plus WAF-filtered public traffic, while dev allows the office range. When a credential-stuffing campaign hits prod, Adaptive Protection (rule_visibility = "PREMIUM") surfaces the attacking signature and the team ships a targeted deny rule by bumping the module tag, with the change peer-reviewed and rolled out identically everywhere within minutes.
Best practices
- Layer your rules and reserve priority bands. Keep allow, deny, WAF, and rate-limit rules in non-overlapping numeric ranges (this module uses 1000s/2000s/3000/4000). Predictable bands prevent two teams from claiming the same
priorityand make the evaluation order obvious at a glance. - Always start new WAF and deny rules in
preview = true. Preview mode logs what would have been blocked without actually blocking it. Watch the Cloud Armor logs in Cloud Logging for false positives, tune sensitivity, then promote the rule out of preview — never ship a fresh block rule straight to enforcement. - Tune the preconfigured WAF, do not just enable it. The bundled
sqli/xssrule sets are noisy at full strength. Use sensitivity levels and per-signature exclusions (evaluatePreconfiguredExprwith opt-out rule IDs) so legitimate payloads — base64 blobs, rich query params — are not flagged as injection. - Enable Adaptive Protection on internet-facing policies. Static rules cannot anticipate volumetric L7 floods. Adaptive Protection’s ML baseline detects anomalies and can suggest (or auto-deploy, with
PREMIUMvisibility) mitigation rules far faster than a human on-call. - Right-size rate limits per
enforce_on_key. Throttling onIPis the default, but behind a CDN or proxy you may needHTTP_HEADER(e.g. a true-client-IP header) orXFF_IPso you are not banning a shared egress NAT and locking out a whole customer. - Turn on logging and watch the deny ratio. Set
log_levelappropriately and alert on sudden spikes indeny(403)/deny(429)— that is your early-warning signal for both attacks and self-inflicted outages from an over-tight rule.