IaC GCP

Terraform Module: GCP Cloud Armor — One Policy for WAF, Rate Limiting and DDoS

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:

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

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 evaluatePreconfiguredExpr expression must reference a real bundled rule set name (for example sqli-v33-stable or xss-v33-stable). The module composes it from var.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 configlive/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 configlive/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

TerraformGCPCloud ArmorModuleIaC
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