Google Cloud CDN (Content Delivery Network) is a globally distributed caching layer built into Google Cloud’s infrastructure, designed to deliver content to users with minimal latency and high reliability. By leveraging Google’s vast network of edge locations, it stores (caches) content closer to your users, reducing the load on your origin servers and improving response times.
Google Cloud CDN combines speed, scalability, and security—making it a go-to choice for enterprises, startups, and developers looking to deliver content faster to a global audience while reducing infrastructure costs.
High-level architecture
Store objects in a private GCS bucket → expose via a Global HTTPS Load Balancer + Cloud CDN (backend bucket) → protect access with Cloud CDN signed URLs → store the CDN signing key in Secret Manager/VAULT/K8s Secrets → GKE workload (via Workload Identity) retrieves that key and generates signed URLs for customers

Prerequisite
- First set the Project Name
% gcloud config set project <PROJECT_NAME>
Updated property [core/project].
2. Enable the below required services in GCP
% gcloud services enable compute.googleapis.com \
storage.googleapis.com \
container.googleapis.com \
iam.googleapis.com \
secretmanager.googleapis.com
Operation "operations/acf.p2-795088716185-60ca6342-1cfb-425d-9cb6-b807e530aeab" finished successfully.
1) Create a private Cloud Storage bucket and upload files
1.Create a bucket to store your file
% gsutil mb -p my-kubernetes-project-447204 -l us-central1 gs://cdn-gcp-demo
Creating gs://cdn-gcp-demo/...

% touch test.txt
% cat >> test.txt
This is a test cdn-gcp-demo file
^C
% gsutil cp test.txt gs://cdn-gcp-demo/test.txt
Copying file://test.txt [Content-Type=text/plain]...
/ [1 files][ 33.0 B/ 33.0 B]
Operation completed over 1 objects/33.0 B.
2. Set bucket level access permission to sure bucket access is UNIFORM (recommended) and not public
% gsutil uniformbucketlevelaccess set on gs://cdn-gcp-demo
Enabling Uniform bucket-level access for gs://cdn-gcp-demo...
2. Create a backend-bucket (Cloud CDN origin) and an external HTTP(S) load balancer
% gcloud compute backend-buckets create cdn-gcp-backend-bucket \
--gcs-bucket-name=cdn-gcp-demo \
--enable-cdn \
--description="backend bucket for CDN"
Created [https://www.googleapis.com/compute/v1/projects/my-kubernetes-project-447204/global/backendBuckets/cdn-gcp-backend-bucket].
NAME GCS_BUCKET_NAME ENABLE_CDN
cdn-gcp-backend-bucket cdn-gcp-demo True
3) Create a Cloud CDN signing key and add it to the backend-bucket
Cloud CDN supports signing keys (up to 3 per backend). The key must be a 16-byte random value encoded in base64url. You can get the Automatically generated one or generate your own 16-byte random value encoded in base64 url.

4) Restrict direct bucket access and let Cloud CDN fetch objects
Remove public access (we set uniform access earlier).
Add the Cloud CDN cache-fill service account to the bucket with roles/storage.objectViewer
PROJECT_NUM=$(gcloud projects describe PROJECT_ID --format='value(projectNumber)')
gcloud storage buckets add-iam-policy-binding gs://my-cdn-bucket \
--member="serviceAccount:service-${PROJECT_NUM}@cloud-cdn-fill.iam.gserviceaccount.com" \
--role="roles/storage.objectViewer"
Example:
% gcloud storage buckets add-iam-policy-binding gs://cdn-gcp-demo \
--member="serviceAccount:service-79508812345@cloud-cdn-fill.iam.gserviceaccount.com" \
--role="roles/storage.objectViewer"
bindings:
- members:
- projectEditor:my-kubernetes-project-447204
- projectOwner:my-kubernetes-project-447204
role: roles/storage.legacyBucketOwner
- members:
- projectViewer:my-kubernetes-project-447204
role: roles/storage.legacyBucketReader
- members:
- serviceAccount:service-79508812345@cloud-cdn-fill.iam.gserviceaccount.com
role: roles/storage.objectViewer
etag: CAM=
kind: storage#policy
resourceId: projects/_/buckets/cdn-gcp-demo
version: 1
This lets Cloud CDN fetch objects from your private bucket while preventing other direct (public) reads.
5) Create GKE cluster (enable Workload Identity)
If already have the GKE cluster then skip this step.
Use Workload Identity so pods authenticate as a Google SA (no JSON keys).
% gcloud container clusters create my-gke-cluster \
--region=us-central1 \
--num-nodes=1 \
--workload-pool=my-kubernetes-project-447204.svc.id.goog
WARNING: Accessing a Kubernetes Engine cluster requires the kubernetes commandline
client [kubectl]. To install, run
$ gcloud components install kubectl
Creating cluster my-gke-cluster in us-central1... Cluster is being health-checked (Kubernetes Control Plane is healthy)...done.
Created [https://container.googleapis.com/v1/projects/my-kubernetes-project-447204/zones/us-central1/clusters/my-gke-cluster].
To inspect the contents of your cluster, go to: https://console.cloud.google.com/kubernetes/workload_/gcloud/us-central1/my-gke-cluster?project=my-kubernetes-project-447204
kubeconfig entry generated for my-gke-cluster.
NAME LOCATION MASTER_VERSION MASTER_IP MACHINE_TYPE NODE_VERSION NUM_NODES STATUS STACK_TYPE
my-gke-cluster us-central1 1.33.2-gke.1240000 34.170.224.3 e2-medium 1.33.2-gke.1240000 3 RUNNING IPV4
6) Create a Google Service Account (GSA) and a Kubernetes ServiceAccount (KSA) and bind them
Create a GSA (GSA_SIGNER
) that has permission to read the secret (and optionally read objects if your app needs to list objects). Then bind a KSA to that GSA.
% gcloud iam service-accounts create gsa-signer \
--display-name="GSA for CDN signing"
Created service account [gsa-signer].
GSA_EMAIL="gsa-signer@${PROJECT_ID}.iam.gserviceaccount.com"
% gcloud projects add-iam-policy-binding ${PROJECT_ID} \
--member="serviceAccount:${GSA_EMAIL}" \
--role="roles/storage.objectViewer"
Updated IAM policy for project [my-kubernetes-project-447204].
bindings:
- members:
- serviceAccount:service-795088716185@compute-system.iam.gserviceaccount.com
role: roles/compute.serviceAgent
- members:
- serviceAccount:service-795088716185@gcp-sa-gkenode.iam.gserviceaccount.com
role: roles/container.defaultNodeServiceAgent
- members:
- serviceAccount:service-795088716185@container-engine-robot.iam.gserviceaccount.com
role: roles/container.serviceAgent
- members:
- serviceAccount:service-795088716185@containerregistry.iam.gserviceaccount.com
role: roles/containerregistry.ServiceAgent
- members:
- serviceAccount:795088716185-compute@developer.gserviceaccount.com
- serviceAccount:795088716185@cloudservices.gserviceaccount.com
role: roles/editor
- members:
- serviceAccount:service-795088716185@gcp-sa-networkconnectivity.iam.gserviceaccount.com
role: roles/networkconnectivity.serviceAgent
- members:
- user:huzefa.hamdard53@gmail.com
role: roles/owner
- members:
- serviceAccount:service-795088716185@gcp-sa-pubsub.iam.gserviceaccount.com
role: roles/pubsub.serviceAgent
- members:
- serviceAccount:gsa-signer@my-kubernetes-project-447204.iam.gserviceaccount.com
role: roles/storage.objectViewer
etag: BwY8A--vkqI=
version: 1
# Create a Kubernetes SA in a namespace for your app
% kubectl create namespace cdn-ns
namespace/cdn-ns created
% kubectl create serviceaccount ksa-signer -n cdn-ns
serviceaccount/ksa-signer created
# Allow the KSA to impersonate/use the GSA (Workload Identity binding)
% gcloud iam service-accounts add-iam-policy-binding ${GSA_EMAIL} \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:${PROJECT_ID}.svc.id.goog[my-app/ksa-signer]"
Updated IAM policy for serviceAccount [gsa-signer@my-kubernetes-project-447204.iam.gserviceaccount.com].
bindings:
- members:
- serviceAccount:my-kubernetes-project-447204.svc.id.goog[my-app/ksa-signer]
role: roles/iam.workloadIdentityUser
etag: BwY8A_-n9ko=
version: 1
# Annotate the Kubernetes SA so GKE knows which GSA it should use
% kubectl annotate serviceaccount ksa-signer -n cdn-ns \
iam.gke.io/gcp-service-account=${GSA_EMAIL}
serviceaccount/ksa-signer annotated
7)Python script to generate the CDN signed URL
Script will perform following operation
- Use Workload Identity (application default credentials) — your pod will run as the KSA and be authenticated as the GSA.
- Access Secret Manager to fetch the base64url key.
- Use the Cloud CDN signing algorithm (HMAC-SHA1 over the URL +
Expires
+KeyName
) and appendExpires
,KeyName
,Signature
query parameters. - Return that signed URL to the customer.
NOTE: To test I have put the generated signed-key (Step3) directly but for production usage you can store in the google secret manager and fetch from it. Or create a deployment and refer the secret from kubernetes secrets.
import base64
import hmac
import hashlib
from datetime import datetime, timezone, timedelta
# === CONFIG ===
# Replace with your actual base64url key from `cdn-key-file`
BASE64URL_KEY = "ur3r32r3297942342-abc==" # example only
KEY_NAME = "gcp-cdn-sign-key" # must match the backend-bucket key name
EXPIRATION_SECONDS = 60 * 30 # URL valid for 30 minutes
CDN_BASE_URL = "http://34.102.152.162" # your CDN/LB domain
def sign_cdn_url(filename: str) -> str:
"""
filename: name of the file in the bucket (root level, e.g., 'test.txt')
Returns: Signed Cloud CDN URL
"""
raw_key = base64.urlsafe_b64decode(BASE64URL_KEY)
expires = int((datetime.now(timezone.utc) + timedelta(seconds=EXPIRATION_SECONDS)).timestamp())
unsigned_url = f"{CDN_BASE_URL}/{filename}?Expires={expires}&KeyName={KEY_NAME}"
signature = hmac.new(raw_key, unsigned_url.encode("utf-8"), hashlib.sha1).digest()
sig_b64url = base64.urlsafe_b64encode(signature).decode("utf-8")
return f"{unsigned_url}&Signature={sig_b64url}"
# === TEST ===
if __name__ == "__main__":
signed_url = sign_cdn_url("test.txt")
print("Signed URL:", signed_url)
% python3 cloud-cdn-signed-url.py
Signed URL: https://34.102.152.162/test.txt?Expires=1754840058&KeyName=gcp-cdn-sign-key&Signature=nILQUBWeXwfSx3ZF1SvCB2Hry30=
8. Test the CDN url
Curl the above generated signed url and see the output.
Below are the important parameters to check in the output
age
: >0 means the object is being served from cache (in seconds since cached).x-cache
:HIT
means the response came from Cloud CDN’s edge cache;MISS
means it was fetched from the origin (your GCS bucket) and cached for next time.via
: often shows1.1 google
indicating Google’s CDN layer.
% curl -I "http://34.102.152.162/test.txt?Expires=1754842540&KeyName=gcp-cdn-sign-key&Signature=-rHHqW8i8pN_rupwigGH4JjYRvc="
HTTP/1.1 200 OK
x-guploader-uploadid: ABgVH8_E7cPIf7xgnBQmXYSRA0kMYI1zUcbYqyrheznpERa06GCQ50NKHKClnoTeZKmLGJN5
x-goog-generation: 1754838869485428
x-goog-metageneration: 1
x-goog-stored-content-encoding: identity
x-goog-stored-content-length: 33
x-goog-hash: crc32c=NnbUqQ==
x-goog-hash: md5=gSa9nBf298OvhydB+IzBLA==
x-goog-storage-class: STANDARD
accept-ranges: bytes
Content-Length: 33
server: UploadServer
via: 1.1 google
Date: Sun, 10 Aug 2025 15:14:36 GMT
Last-Modified: Sun, 10 Aug 2025 15:14:29 GMT
ETag: "8126bd9c17f6f7c3af872741f88cc12c"
Content-Type: text/plain
Age: 1890
Cache-Control: private,max-age=0
% curl "http://34.102.152.162/test.txt?Expires=1754842540&KeyName=gcp-cdn-sign-key&Signature=-rHHqW8i8pN_rupwigGH4JjYRvc="
This is a test cdn-gcp-demo file
9. Check in GCP console
You can also check in the GCP console
