IaC GCP

Terraform Module: GCP Cloud CDN — Edge caching on the global LB, codified

Cloud CDN on GCP is not a standalone product you point a hostname at. It is a flag — enable_cdn = true — that you flip on a backend that already sits behind a global external Application Load Balancer. That design is elegant once you understand it, but it means the CDN configuration is tangled up with backend services, backend buckets, URL maps, and forwarding rules. Copy-pasting that wiring into every project is how you end up with five subtly different cache key policies and a 2 AM debugging session over why one service caches Authorization headers. This module pulls the CDN-bearing backend into one versioned, var-driven place.

Quick take — A reusable Terraform module for GCP Cloud CDN built on google_compute_backend_service with enable_cdn and cdn_policy — cache modes, TTLs, negative caching, plus a backend bucket option for static content. 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_cdn" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-cdn?ref=v1.0.0"

  project_id = "..."  # GCP project ID owning the backend and CDN config.
  name       = "..."  # Name of the backend service or bucket resource.
}

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

What this module is

Cloud CDN rides on the Google global load balancer’s backends. When you set enable_cdn = true on a google_compute_backend_service (dynamic origins — instance groups, NEGs, serverless services) or a google_compute_backend_bucket (static content in a GCS bucket), GCP caches responses at its global edge POPs. The behaviour is governed by a cdn_policy block: the cache mode decides what gets cached and how TTLs are derived, and the rest of the policy tunes TTLs, negative caching, cache keys, and signed-request enforcement.

The three cache modes matter most:

This module wraps a single backend (service or bucket) plus its cdn_policy so the cache mode, default/max/client TTLs, negative caching policy, and cache key policy are inputs rather than hand-rolled HCL. It deliberately stops at the backend boundary: you attach the resulting backend to your own google_compute_url_map, so the same CDN backend can serve /static/* while your API backend serves /api/* under one LB. Wrapping it in a module gives you one reviewed default, consistent negative caching (so a single 404 storm does not hammer your origin), and a clean upgrade path via a ref= tag.

When to use it

Reach for something else when: you only need a single static site with zero dynamic routing (Firebase Hosting may be simpler), you are not (and will not be) using the global external ALB, or your traffic is purely internal/regional — Cloud CDN is for the global external HTTP(S) load balancer.

Module structure

terraform-module-gcp-cloud-cdn/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # Exactly one of backend_bucket_name / backend_service mode is active.
  use_bucket = var.gcs_bucket_name != null
}

# ---------------------------------------------------------------------------
# Option A: Backend bucket (static content served from a GCS bucket)
# ---------------------------------------------------------------------------
resource "google_compute_backend_bucket" "this" {
  count = local.use_bucket ? 1 : 0

  project     = var.project_id
  name        = var.name
  description = var.description
  bucket_name = var.gcs_bucket_name
  enable_cdn  = true

  # Compress text responses at the edge when the client supports it.
  compression_mode = var.compression_mode

  cdn_policy {
    cache_mode        = var.cache_mode
    client_ttl        = var.client_ttl
    default_ttl       = var.default_ttl
    max_ttl           = var.max_ttl
    # Serve stale content while revalidating in the background.
    serve_while_stale = var.serve_while_stale

    negative_caching = var.negative_caching

    dynamic "negative_caching_policy" {
      for_each = var.negative_caching ? var.negative_caching_policy : []
      content {
        code = negative_caching_policy.value.code
        ttl  = negative_caching_policy.value.ttl
      }
    }

    # Cache key policy is only valid for backend buckets when
    # include/exclude HTTP query params are configured.
    dynamic "cache_key_policy" {
      for_each = length(var.bucket_query_string_whitelist) > 0 ? [1] : []
      content {
        query_string_whitelist = var.bucket_query_string_whitelist
      }
    }
  }
}

# ---------------------------------------------------------------------------
# Option B: Backend service (dynamic origins: MIGs, NEGs, serverless)
# ---------------------------------------------------------------------------
resource "google_compute_backend_service" "this" {
  count = local.use_bucket ? 0 : 1

  project               = var.project_id
  name                  = var.name
  description           = var.description
  protocol              = var.protocol
  port_name             = var.port_name
  load_balancing_scheme = "EXTERNAL_MANAGED"
  timeout_sec           = var.timeout_sec
  enable_cdn            = true
  compression_mode      = var.compression_mode

  # Health checks are required for instance-group / zonal-NEG backends.
  # For serverless NEGs (Cloud Run/Functions/App Engine) leave the list empty.
  health_checks = var.health_check_ids

  dynamic "backend" {
    for_each = var.backend_groups
    content {
      group           = backend.value.group
      balancing_mode  = backend.value.balancing_mode
      capacity_scaler = backend.value.capacity_scaler
      max_utilization = backend.value.max_utilization
    }
  }

  cdn_policy {
    cache_mode                   = var.cache_mode
    client_ttl                   = var.client_ttl
    default_ttl                  = var.default_ttl
    max_ttl                      = var.max_ttl
    serve_while_stale            = var.serve_while_stale
    signed_url_cache_max_age_sec = var.signed_url_cache_max_age_sec

    negative_caching = var.negative_caching

    dynamic "negative_caching_policy" {
      for_each = var.negative_caching ? var.negative_caching_policy : []
      content {
        code = negative_caching_policy.value.code
        ttl  = negative_caching_policy.value.ttl
      }
    }

    cache_key_policy {
      include_host          = var.cache_key.include_host
      include_protocol      = var.cache_key.include_protocol
      include_query_string  = var.cache_key.include_query_string
      query_string_whitelist = var.cache_key.query_string_whitelist
      include_http_headers   = var.cache_key.include_http_headers
      include_named_cookies  = var.cache_key.include_named_cookies
    }
  }

  # Edge security policy (Cloud Armor) at the CDN tier, when supplied.
  edge_security_policy = var.edge_security_policy
}
# variables.tf

variable "project_id" {
  type        = string
  description = "GCP project ID that owns the backend and CDN configuration."
}

variable "name" {
  type        = string
  description = "Name for the backend service or backend bucket resource."
}

variable "description" {
  type        = string
  description = "Human-readable description of the backend."
  default     = "Managed by Terraform - Cloud CDN backend"
}

# ---- Mode selection -------------------------------------------------------

variable "gcs_bucket_name" {
  type        = string
  description = "If set, a CDN-enabled backend BUCKET is created for this GCS bucket. Leave null to create a backend SERVICE for dynamic origins."
  default     = null
}

# ---- Backend service inputs ----------------------------------------------

variable "protocol" {
  type        = string
  description = "Backend protocol for the backend service (HTTP, HTTPS, HTTP2)."
  default     = "HTTPS"

  validation {
    condition     = contains(["HTTP", "HTTPS", "HTTP2"], var.protocol)
    error_message = "protocol must be one of HTTP, HTTPS, HTTP2."
  }
}

variable "port_name" {
  type        = string
  description = "Named port on the backend instance groups (ignored for serverless NEGs)."
  default     = "http"
}

variable "timeout_sec" {
  type        = string
  description = "Backend response timeout in seconds."
  default     = "30"
}

variable "health_check_ids" {
  type        = list(string)
  description = "Self-links of health checks. Required for MIG/zonal-NEG backends; empty for serverless NEGs."
  default     = []
}

variable "backend_groups" {
  type = list(object({
    group           = string
    balancing_mode  = optional(string, "UTILIZATION")
    capacity_scaler = optional(number, 1.0)
    max_utilization = optional(number, 0.8)
  }))
  description = "Backend groups (instance group or NEG self-links) attached to the backend service."
  default     = []
}

variable "edge_security_policy" {
  type        = string
  description = "Self-link of a Cloud Armor edge security policy to attach at the CDN tier. Null to skip."
  default     = null
}

# ---- CDN policy (shared) --------------------------------------------------

variable "cache_mode" {
  type        = string
  description = "CDN cache mode: USE_ORIGIN_HEADERS, CACHE_ALL_STATIC, or FORCE_CACHE_ALL."
  default     = "CACHE_ALL_STATIC"

  validation {
    condition     = contains(["USE_ORIGIN_HEADERS", "CACHE_ALL_STATIC", "FORCE_CACHE_ALL"], var.cache_mode)
    error_message = "cache_mode must be USE_ORIGIN_HEADERS, CACHE_ALL_STATIC, or FORCE_CACHE_ALL."
  }
}

variable "client_ttl" {
  type        = number
  description = "Max TTL (seconds) advertised to clients via Cache-Control max-age."
  default     = 3600
}

variable "default_ttl" {
  type        = number
  description = "Default TTL (seconds) for cached responses lacking explicit cache headers."
  default     = 3600
}

variable "max_ttl" {
  type        = number
  description = "Upper bound (seconds) on TTL regardless of origin headers. Not used with FORCE_CACHE_ALL."
  default     = 86400
}

variable "serve_while_stale" {
  type        = number
  description = "Seconds a stale response may be served while revalidating in the background. 0 disables."
  default     = 86400
}

variable "compression_mode" {
  type        = string
  description = "Edge compression mode: AUTOMATIC or DISABLED."
  default     = "AUTOMATIC"

  validation {
    condition     = contains(["AUTOMATIC", "DISABLED"], var.compression_mode)
    error_message = "compression_mode must be AUTOMATIC or DISABLED."
  }
}

variable "signed_url_cache_max_age_sec" {
  type        = number
  description = "Max age (seconds) for signed-URL cache entries on a backend service. 0 to disable signed-URL caching."
  default     = 0
}

# ---- Negative caching -----------------------------------------------------

variable "negative_caching" {
  type        = bool
  description = "Cache common error responses (e.g. 404, 410) to shield the origin from error storms."
  default     = true
}

variable "negative_caching_policy" {
  type = list(object({
    code = number
    ttl  = number
  }))
  description = "Per-status-code negative caching TTLs. Valid codes: 300,301,302,307,308,404,405,410,421,451,500,502,503,504."
  default = [
    { code = 404, ttl = 120 },
    { code = 410, ttl = 120 },
  ]
}

# ---- Cache key policy (backend service) -----------------------------------

variable "cache_key" {
  type = object({
    include_host           = optional(bool, true)
    include_protocol       = optional(bool, true)
    include_query_string   = optional(bool, true)
    query_string_whitelist = optional(list(string), [])
    include_http_headers   = optional(list(string), [])
    include_named_cookies  = optional(list(string), [])
  })
  description = "Cache key policy for the backend service. Narrow this to maximise hit ratio (e.g. drop query string for fingerprinted assets)."
  default     = {}
}

variable "bucket_query_string_whitelist" {
  type        = list(string)
  description = "Query params to include in the cache key for a backend BUCKET. Empty means query string is ignored."
  default     = []
}
# outputs.tf

output "backend_id" {
  description = "ID of the created backend (service or bucket)."
  value       = local.use_bucket ? google_compute_backend_bucket.this[0].id : google_compute_backend_service.this[0].id
}

output "backend_self_link" {
  description = "Self-link of the backend — attach this to a google_compute_url_map default_service / path rule."
  value       = local.use_bucket ? google_compute_backend_bucket.this[0].self_link : google_compute_backend_service.this[0].self_link
}

output "backend_name" {
  description = "Name of the backend resource."
  value       = var.name
}

output "is_backend_bucket" {
  description = "True if a backend bucket was created, false if a backend service."
  value       = local.use_bucket
}

output "cdn_enabled" {
  description = "Always true — the module unconditionally enables Cloud CDN on the backend."
  value       = true
}

How to use it

A static-content example (SPA bundle in GCS) and a dynamic example (Cloud Run via serverless NEG), both attached to a URL map.

# --- Static assets: CDN-enabled backend bucket -----------------------------
module "cloud_cdn_static" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-cdn?ref=v1.0.0"

  project_id      = "kloudvin-prod"
  name            = "kv-static-assets-cdn"
  gcs_bucket_name = google_storage_bucket.assets.name

  cache_mode  = "CACHE_ALL_STATIC"
  client_ttl  = 3600
  default_ttl = 86400      # 1 day
  max_ttl     = 31536000   # 1 year for fingerprinted assets

  # Fingerprinted filenames already bust cache → ignore query string.
  bucket_query_string_whitelist = []

  negative_caching = true
  negative_caching_policy = [
    { code = 404, ttl = 60 },
  ]
}

# --- Dynamic app: CDN-enabled backend service over a serverless NEG --------
module "cloud_cdn_app" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-cdn?ref=v1.0.0"

  project_id = "kloudvin-prod"
  name       = "kv-app-cdn"
  protocol   = "HTTPS"

  backend_groups = [
    { group = google_compute_region_network_endpoint_group.run_neg.id }
  ]
  health_check_ids = [] # serverless NEGs do not take health checks

  cache_mode  = "USE_ORIGIN_HEADERS" # the app controls cacheability via headers
  default_ttl = 600
  max_ttl     = 3600

  cache_key = {
    include_query_string  = true
    query_string_whitelist = ["v", "lang"] # only these params vary the cache
    include_named_cookies  = []            # never key on session cookies
  }

  negative_caching = true
}

Downstream, both backends hang off a single URL map and the standard global LB plumbing:

resource "google_compute_url_map" "default" {
  name            = "kv-edge-lb"
  default_service = module.cloud_cdn_app.backend_self_link

  host_rule {
    hosts        = ["www.kloudvin.com"]
    path_matcher = "main"
  }

  path_matcher {
    name            = "main"
    default_service = module.cloud_cdn_app.backend_self_link

    path_rule {
      paths   = ["/static/*", "/assets/*"]
      service = module.cloud_cdn_static.backend_self_link
    }
  }
}

resource "google_compute_target_https_proxy" "default" {
  name             = "kv-edge-https-proxy"
  url_map          = google_compute_url_map.default.id
  ssl_certificates = [google_compute_managed_ssl_certificate.default.id]
}

resource "google_compute_global_forwarding_rule" "https" {
  name                  = "kv-edge-fr-https"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  port_range            = "443"
  target                = google_compute_target_https_proxy.default.id
  ip_address            = google_compute_global_address.default.id
}

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_cdn/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-cdn?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  name = "..."
}

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

cd live/prod/cloud_cdn && 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 owning the backend and CDN config.
name string Yes Name of the backend service or bucket resource.
description string "Managed by Terraform - Cloud CDN backend" No Description of the backend.
gcs_bucket_name string null No If set, creates a CDN-enabled backend bucket for this GCS bucket; otherwise a backend service.
protocol string "HTTPS" No Backend service protocol (HTTP, HTTPS, HTTP2).
port_name string "http" No Named port on backend instance groups (ignored for serverless NEGs).
timeout_sec string "30" No Backend response timeout in seconds.
health_check_ids list(string) [] No Health check self-links; required for MIG/zonal-NEG backends.
backend_groups list(object) [] No Instance group / NEG self-links attached to the backend service.
edge_security_policy string null No Cloud Armor edge security policy self-link for the CDN tier.
cache_mode string "CACHE_ALL_STATIC" No USE_ORIGIN_HEADERS, CACHE_ALL_STATIC, or FORCE_CACHE_ALL.
client_ttl number 3600 No Max TTL advertised to clients via Cache-Control max-age.
default_ttl number 3600 No Default TTL for responses lacking explicit cache headers.
max_ttl number 86400 No Upper TTL bound regardless of origin headers (n/a for FORCE_CACHE_ALL).
serve_while_stale number 86400 No Seconds a stale entry may be served while revalidating.
compression_mode string "AUTOMATIC" No Edge compression: AUTOMATIC or DISABLED.
signed_url_cache_max_age_sec number 0 No Cache max-age for signed-URL entries on a backend service (0 disables).
negative_caching bool true No Cache error responses (404/410, etc.) to shield the origin.
negative_caching_policy list(object) [{404,120},{410,120}] No Per-status-code negative caching TTLs.
cache_key object {} No Cache key policy for the backend service (host/protocol/query/headers/cookies).
bucket_query_string_whitelist list(string) [] No Query params included in the backend bucket cache key.

Outputs

Name Description
backend_id ID of the created backend (service or bucket).
backend_self_link Self-link to attach to a google_compute_url_map default service or path rule.
backend_name Name of the backend resource.
is_backend_bucket True if a backend bucket was created, false for a backend service.
cdn_enabled Always true — Cloud CDN is unconditionally enabled on the backend.

Enterprise scenario

A media company serves a global news site: a Next.js app on Cloud Run plus a large library of images and video posters in GCS. The platform team deploys this module twice behind one global external ALB — a USE_ORIGIN_HEADERS backend service over a serverless NEG for the rendered pages (so editorial can control per-route cacheability with Cache-Control headers and serve_while_stale keeps the site up during origin blips), and a CACHE_ALL_STATIC backend bucket with a one-year max_ttl for fingerprinted media. Negative caching on the app backend absorbs the inevitable 404 floods from expired article URLs and bot scans, keeping Cloud Run scaled down and the bill predictable. Because both backends are produced by the same pinned module, the cache key policy — never keying on session cookies — is identical and auditable across every service the company runs.

Best practices

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