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:
CACHE_ALL_STATIC— caches static content (images, CSS, JS, media) by content type even withoutCache-Controlheaders, while still respecting explicit cache headers when present. The pragmatic default for a website.USE_ORIGIN_HEADERS— caches only when the origin sends validCache-Control/Expiresheaders. Maximum origin control; nothing is cached unless the origin says so.FORCE_CACHE_ALL— caches every response regardless of headers (ignores private/no-store). Powerful and dangerous; only for backends that serve exclusively public, non-personalized content.
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
- You run a global external Application Load Balancer and want edge caching on one or more of its backends.
- You serve static assets from GCS (a SPA bundle, downloads, media) and want a
backend_bucketfronted by CDN instead of public bucket URLs. - You have dynamic backends (managed instance groups, container NEGs, Cloud Run via serverless NEG) whose cacheable responses should be served from the edge.
- You need consistent, auditable cache policy — cache keys, TTL ceilings, negative caching — across many services instead of per-project drift.
- You want signed URLs / signed cookies enforced at the edge for paid or gated content.
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 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_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
- Make the cache key as narrow as correctness allows. Each header, cookie, or query param you include in the key fragments the cache and tanks the hit ratio. For fingerprinted assets, drop the query string entirely; for an app, whitelist only the params that genuinely change the response and never key on
Authorizationor session cookies (which would also cache personalized content under shared keys). - Match the cache mode to the origin’s behaviour, not your optimism.
FORCE_CACHE_ALLwill happily cache a logged-in user’s dashboard and serve it to the world — reserve it for backends that emit nothing private. Default toCACHE_ALL_STATICfor content sites andUSE_ORIGIN_HEADERSwhen the app sets correct headers. - Set a sane
max_ttland let fingerprinting do cache busting. A one-year TTL onapp.a1b2c3.jsis safe because the filename changes on every deploy; the same TTL onindex.htmlis a footgun. Keep entry HTML short-lived and immutable assets long-lived. - Use signed URLs / signed cookies for gated content rather than caching private responses. Enable key generation on the backend, set
signed_url_cache_max_age_secso authorized fetches still hit the edge, and keepcache_modehonest so unsigned requests are not served stale paid content. - Keep negative caching on, with short TTLs. Caching 404/410 for a minute or two stops error storms from melting your origin, but long negative TTLs delay recovery once you actually publish the missing resource — 60–120s is the sweet spot.
- Invalidate sparingly and treat it as a break-glass tool. Cache invalidation (
gcloud compute url-maps invalidate-cdn-cache) is rate-limited and global; design for it to be rare by versioning asset paths. When you do invalidate, prefer narrow path patterns over/*, and remember invalidation propagates over seconds-to-minutes, not instantly.