Back to blog
Best PracticesCost OptimizationGCPNetworkingPerformance

Load Balancer CDN Not Enabled: Why It Matters and How to Fix It on GCP

Learn why a GCP load balancer backend service without Cloud CDN costs you money and latency, plus step-by-step CLI, Console, and Terraform fixes.

TL;DR

This check flags GCP load balancer backend services that run without Cloud CDN. Without CDN, every request hits your origin, which inflates latency, egress cost, and origin load. Fix it by enabling Cloud CDN on the backend service with gcloud compute backend-services update --enable-cdn.

If you run a public-facing application behind a Google Cloud HTTP(S) load balancer, your backend service is doing more work than it needs to. Cloud CDN sits in front of that backend and caches responses at Google's edge locations, so repeat requests for the same content never touch your origin. The Load Balancer CDN Not Enabled check (lb_nocdn) catches backend services where this is switched off.

This is not a critical security hole on its own, but it has direct cost, performance, and resilience consequences that compound at scale. Here is what the check looks for, why it matters, and how to fix it properly.


What this check detects

The check inspects GCP external HTTP(S) load balancer backend services and reports any that have Cloud CDN disabled. In the API, this maps to the enableCDN field on a backendService resource being false or absent.

A backend service can sit behind either a managed instance group, a network endpoint group (NEG), or a backend bucket. Cloud CDN can be enabled on both backend services and backend buckets. This particular check targets backend services, where caching is most often overlooked because teams assume CDN is "automatic" once a load balancer is in place. It is not. You have to turn it on.

Note: Cloud CDN only works with the global external Application Load Balancer (formerly the HTTP(S) load balancer). It is not available on internal load balancers or regional network load balancers, so the check is scoped to the load balancer types where CDN is a valid option.


Why it matters

The impact lands in three places: cost, performance, and origin stability.

Egress cost

Serving content directly from a Compute Engine backend or a GKE service means every byte leaves through standard GCP egress pricing. Cloud CDN cache fills and cache hits are billed at lower cache egress rates, and a cached response never re-fetches from origin. For a site serving static assets, images, or API responses with predictable cache keys, the difference on the monthly bill is measurable.

Latency for users

Without CDN, a user in Sydney requesting content from a backend in us-central1 pays the full round-trip every time. With Cloud CDN, that content is served from a nearby Google edge cache after the first request. This is the difference between a 20ms and a 200ms time-to-first-byte for cacheable responses.

Origin load and resilience

A backend without CDN absorbs the full request volume. During a traffic spike, a viral link, or a scraping bot, your instances scale up (and you pay for the extra capacity) to serve content that could have been cached once and replayed thousands of times. Cloud CDN acts as a shock absorber. It also offers a degree of protection during partial origin outages, since cached content can keep serving while you recover.

Warning: CDN is not a cure for caching dynamic, per-user content. Caching a response that contains session data or personalized output can leak one user's data to another. Always verify what is cacheable before enabling CDN on a backend that serves authenticated responses. More on this below.


How to fix it

You can enable Cloud CDN on an existing backend service through the gcloud CLI, the Console, or your infrastructure-as-code tooling.

Option 1: gcloud CLI

Enable CDN on a global backend service:

gcloud compute backend-services update my-backend-service \
  --global \
  --enable-cdn

Once enabled, configure the cache mode. The default mode caches based on origin response headers, but most teams want explicit control. CACHE_ALL_STATIC is a sensible starting point for sites that serve a mix of static and dynamic content:

gcloud compute backend-services update my-backend-service \
  --global \
  --enable-cdn \
  --cache-mode=CACHE_ALL_STATIC \
  --default-ttl=3600 \
  --max-ttl=86400 \
  --client-ttl=3600

The three cache modes are:

  • USE_ORIGIN_HEADERS — Cloud CDN respects Cache-Control and Expires headers from your origin. Safest when your application already sets correct caching headers.
  • CACHE_ALL_STATIC — Caches static content (based on content type and headers) and passes dynamic content through.
  • FORCE_CACHE_ALL — Caches all responses regardless of headers. Use with extreme care, this can cache private content.

Danger: Never use FORCE_CACHE_ALL on a backend that serves authenticated or personalized responses. It overrides origin cache directives and will cache (and replay) responses containing one user's private data to other users. This is a real and well-documented cause of data exposure incidents.

Option 2: Google Cloud Console

  1. Go to Network Services > Load balancing.
  2. Select your load balancer and click Edit.
  3. Open the Backend configuration section and select the backend service.
  4. Check Enable Cloud CDN.
  5. Choose a cache mode and set TTLs.
  6. Save and update the load balancer.

Option 3: Terraform

If you manage your load balancer with Terraform, set enable_cdn and a cdn_policy block on the backend service resource:

resource "google_compute_backend_service" "default" {
  name                  = "my-backend-service"
  protocol              = "HTTPS"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  enable_cdn            = true

  cdn_policy {
    cache_mode        = "CACHE_ALL_STATIC"
    default_ttl       = 3600
    max_ttl           = 86400
    client_ttl        = 3600
    negative_caching  = true

    cache_key_policy {
      include_host         = true
      include_protocol     = true
      include_query_string = false
    }
  }

  backend {
    group = google_compute_instance_group_manager.default.instance_group
  }

  health_checks = [google_compute_health_check.default.id]
}

Tip: The cache_key_policy block matters more than people expect. By default, query strings are part of the cache key, so ?utm_source=twitter and ?utm_source=email create separate cache entries for identical content. Set include_query_string = false (or whitelist only the params that change the response) to raise your cache hit ratio.


Verify it worked

After enabling CDN, confirm responses are being cached. Send two requests for the same cacheable URL and inspect the Age and Via response headers, or check the cache status header:

curl -sI https://your-domain.example.com/static/logo.png | grep -i -E 'age|via|cache'

A cache hit shows a non-zero Age header on the second request. You can also view cache hit ratio metrics in Cloud Monitoring under the loadbalancing.googleapis.com/https/backend_request_count metric, filtered by the cache_result label.


How to prevent it from happening again

Manual fixes drift. The reliable way to keep CDN on across your estate is to enforce it at provisioning time.

Policy-as-code with OPA / Conftest

If you gate Terraform plans in CI, a Rego policy can fail any backend service that omits CDN on an external load balancer:

package main

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "google_compute_backend_service"
  scheme := resource.change.after.load_balancing_scheme
  scheme == "EXTERNAL_MANAGED"
  not resource.change.after.enable_cdn
  msg := sprintf("Backend service '%s' on an external load balancer must enable Cloud CDN", [resource.change.after.name])
}

Wire this into your pipeline against a plan output:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
conftest test tfplan.json --policy policy/

Organization policy and module defaults

For teams using shared Terraform modules, the cleanest fix is to set enable_cdn = true as the module default for any external load balancer module. Engineers consuming the module get caching for free unless they explicitly opt out, which inverts the default in your favor.

Tip: Run the Lensix lb_nocdn check on a schedule rather than only at deploy time. Backend services get recreated, imported, or modified outside of IaC more often than anyone admits, and a scheduled scan catches the ones that slip past your pipeline.


Best practices

  • Set correct cache headers at the origin. CDN behavior is only as good as the Cache-Control directives your application returns. Mark static assets with long TTLs and personalized responses with Cache-Control: private, no-store.
  • Separate static and dynamic paths. Route static content (/assets/*, /images/*) to a backend bucket or a CDN-enabled backend, and keep dynamic API traffic on its own backend with caching tuned or disabled.
  • Tune your cache key. Strip irrelevant query parameters, and decide deliberately whether host and protocol should be part of the key. A loose cache key fragments your cache and tanks your hit ratio.
  • Enable negative caching for error responses (404, 410) so a flood of requests for missing content does not hammer your origin.
  • Use signed URLs or signed cookies when you need to serve private content through CDN, rather than reaching for FORCE_CACHE_ALL.
  • Monitor cache hit ratio. A low hit ratio usually means a cache key problem or missing origin headers, not a CDN that "isn't working". Treat it as a tuning signal.

Enabling Cloud CDN is a small change with an outsized payoff: lower egress bills, faster page loads, and a backend that survives traffic spikes. The trap is enabling it carelessly and caching something private, so pair the switch with a deliberate look at what your backend actually serves.