Static Hosting on Google Cloud Storage

Organisations standardised on Google Cloud often want their static sites there too, next to the rest of their infrastructure, billed on the same account and deployed with the same identity model. Google Cloud Storage can hold the files, but a bucket alone is not a good production host for a custom domain: it serves HTTP only for bucket-named domains, cannot set security headers per path, and has no deploy previews or edge caching controls. The production pattern puts an external Application Load Balancer with Cloud CDN in front of the bucket, which adds HTTPS with managed certificates, global caching, custom headers and invalidation.

This guide builds that setup for an Eleventy documentation site: the bucket and its website configuration, the load balancer and Cloud CDN, cache and security headers, keyless deploys from GitHub Actions with Workload Identity Federation, and measured performance. It is part of Self-Hosting Static Sites on S3, Nginx and Caddy, and mirrors the AWS pattern in Deploying a Static Site to S3 and CloudFront.

Prerequisites

  • A Google Cloud project with billing, and permission to create buckets, load balancers and IAM bindings.
  • gcloud CLI (or Terraform) and a custom domain whose DNS you control.
  • An Eleventy (or any) static build in _site/.

Step 1: The Bucket

Create a bucket, make objects publicly readable (the load balancer needs to read them; there is no origin access control equivalent for backend buckets), and set the website configuration so directory requests serve index.html and missing objects serve the 404 page:

gcloud storage buckets create gs://docs-example-com --location=EU --uniform-bucket-level-access
gcloud storage buckets add-iam-policy-binding gs://docs-example-com --member=allUsers --role=roles/storage.objectViewer
gcloud storage buckets update gs://docs-example-com --web-main-page-suffix=index.html --web-error-page=404.html

The bucket's contents are public by necessity, so nothing private may ever be uploaded to it; previews use a separate bucket with its own access controls. The 404 page is served with status 404 through the load balancer, which matters for search engines.

Step 2: Load Balancer and Cloud CDN

An external Application Load Balancer with a backend bucket and Cloud CDN enabled, a managed certificate and an HTTPS forwarding rule. In Terraform, the essential pieces:

resource "google_compute_backend_bucket" "docs" {
  name        = "docs-backend"
  bucket_name = google_storage_bucket.docs.name
  enable_cdn  = true
  cdn_policy {
    cache_mode        = "USE_ORIGIN_HEADERS"
    serve_while_stale = 86400
    negative_caching  = true
    negative_caching_policy { code = 404  ttl = 300 }
  }
  custom_response_headers = [
    "Strict-Transport-Security: max-age=31536000; includeSubDomains",
    "X-Content-Type-Options: nosniff",
    "Referrer-Policy: strict-origin-when-cross-origin",
  ]
}
resource "google_compute_managed_ssl_certificate" "docs" {
  name = "docs-cert"
  managed { domains = ["docs.example.com"] }
}

USE_ORIGIN_HEADERS makes Cloud CDN respect the Cache-Control metadata on each object, which the deploy sets per file type. serve_while_stale keeps serving cached content if the bucket is briefly unreachable. Negative caching of 404s for five minutes keeps scanner traffic off the bucket, the same idea as Caching 404s and Redirects at the Edge. An HTTP-to-HTTPS redirect is a second forwarding rule with a URL map that redirects everything.

Architecture of a static site on Cloud Storage with a load balancer and Cloud CDN A reader connects to the global anycast IP of the external Application Load Balancer, which terminates HTTPS with a managed certificate. Cloud CDN serves cached responses from the nearest edge, adding custom security headers. On a miss, the backend bucket reads the object from Cloud Storage in the EU multi-region, using the object's Cache-Control metadata. Request path through the Google Cloud setup Global anycast IP HTTPS, HTTP/3, managed cert one IP worldwide Cloud CDN edge cache, custom headers respects object Cache-Control Backend bucket index.html suffix + 404 page clean URLs Cloud Storage bucket EU multi-region public read objects
The bucket never faces readers directly for the custom domain; all traffic goes through the load balancer.

Step 3: Keyless Deploys From GitHub Actions

Service account keys are long-lived secrets. Workload Identity Federation lets GitHub Actions exchange its OIDC token for short-lived Google credentials instead, with a pool and provider that only trust this repository:

gcloud iam workload-identity-pools create github --location=global
gcloud iam workload-identity-pools providers create-oidc github-actions --location=global --workload-identity-pool=github \
  --issuer-uri=https://token.actions.githubusercontent.com \
  --attribute-mapping=google.subject=assertion.sub,attribute.repository=assertion.repository \
  --attribute-condition="assertion.repository=='example/docs'"
gcloud iam service-accounts add-iam-policy-binding deploy-docs@PROJECT.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/github/attribute.repository/example/docs"

The deploy service account has roles/storage.objectAdmin on the site bucket only and roles/compute.loadBalancerAdmin scoped by condition for invalidations. The workflow:

permissions: { contents: read, id-token: write }
steps:
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: projects/NUM/locations/global/workloadIdentityPools/github/providers/github-actions
      service_account: deploy-docs@PROJECT.iam.gserviceaccount.com
  - uses: google-github-actions/setup-gcloud@v2
  - run: |
      gcloud storage rsync _site gs://docs-example-com --recursive --checksums-only --exclude='.*\.html$' \
        --cache-control='public, max-age=31536000, immutable'
      gcloud storage rsync _site gs://docs-example-com --recursive --checksums-only --delete-unmatched-destination-objects \
        --cache-control='public, max-age=0, s-maxage=300' --content-type='text/html; charset=utf-8' --include='.*\.html$'
      gcloud compute url-maps invalidate-cdn-cache docs-urlmap --path='/*' --async

--checksums-only compares content hashes, so unchanged files are not re-uploaded, as in Deploying Only Changed Files. Assets are uploaded before HTML. The same OIDC pattern for AWS is covered in Securing Deploy Credentials with GitHub OIDC.

Deploy flow from GitHub Actions to Cloud Storage with Workload Identity Federation The workflow requests an OIDC token from GitHub. google-github-actions/auth exchanges it through the workload identity pool for short-lived credentials of the deploy service account, allowed only for this repository. gcloud storage rsync uploads changed assets, then changed HTML, comparing checksums. Finally the CDN cache is invalidated for HTML paths. A keyless deploy GitHub OIDC token id-token: write Federation repo-scoped pool Short-lived creds deploy service account rsync by checksum assets, then HTML Invalidate CDN HTML paths
No service account key exists anywhere; credentials expire after an hour.

Step 4: Redirects and Previews

The load balancer's URL map supports redirects — host redirects, path prefix redirects, HTTPS redirects — but is not designed for thousands of individual rules, and changes take a few minutes to propagate. The site kept its 1,400 migration redirects as small static HTML stub pages with <meta http-equiv="refresh"> and canonical links for the rarely used ones, and put the 50 most-visited in the URL map as real 301s. For many redirects with real status codes, a small Cloud Run service or an edge function in front is the alternative.

Pull request previews use a separate bucket, docs-previews-example-com, with a prefix per pull request and a second load balancer path restricted with Identity-Aware Proxy, so previews require Google sign-in. A cleanup job deletes prefixes for closed pull requests, as in Cleaning Up Stale Preview Deployments.

Operating It

A few practices keep the setup healthy. Everything — bucket, IAM bindings, load balancer, certificate, Cloud CDN policy and the workload identity pool — is defined in Terraform, reviewed like application code, so a change to caching or headers goes through a pull request with a plan diff. Object Versioning is enabled with a lifecycle rule that deletes non-current versions after 30 days, which gives a cheap rollback window without unbounded storage growth. Cloud Monitoring alerts on the load balancer's 5xx rate and on the CDN cache hit ratio dropping below 85%, which catches both origin problems and accidental cache-key changes, following Alerting on Cache Hit Ratio Drops. The managed certificate is monitored externally like any other, since managed renewal depends on DNS still pointing at the load balancer's IP.

Cost is predictable and small for a site of this size: a flat forwarding-rule charge for the load balancer, CDN egress by volume, and a few cents of storage. The load balancer's fixed monthly charge is the largest item for low-traffic sites — worth knowing, because for a very small site a managed static host may be cheaper than the do-it-yourself setup.

Measured Impact

The site moved from a single Compute Engine VM running nginx to Cloud Storage with Cloud CDN:

Measurenginx on one VM (europe-west1)GCS + Cloud CDN
TTFB p75, Europe120 ms70 ms
TTFB p75, Asia-Pacific480 ms110 ms
CDN cache hit ratio (HTML / assets)—91% / 99%
Deploy time (typical content change)2 min (rsync + reload)38 s
Long-lived deploy secrets1 SSH key0
Servers to patch10
Time to first byte by region on a VM and on Cloud Storage with Cloud CDN Paired bars of TTFB at the 75th percentile. Europe improved from 120 to 70 milliseconds. North America from 290 to 90 milliseconds. Asia-Pacific from 480 to 110 milliseconds after moving from a single VM in europe-west1 to Cloud Storage behind Cloud CDN. TTFB p75 before and after nginx VM GCS + Cloud CDN Europe 120 ms 70 ms North America 290 ms 90 ms Asia-Pacific 480 ms 110 ms
RUM for Europe and synthetic checks from four regions over two weeks for each setup.

Pitfalls & Rollback

  • Serving the bucket directly on a custom domain. No HTTPS; always go through the load balancer.
  • Uploading private files to the public bucket. Everything in it is world-readable; keep previews and drafts elsewhere.
  • cache_mode that ignores object headers. Use USE_ORIGIN_HEADERS so per-file Cache-Control applies.
  • Service account keys in CI. Use Workload Identity Federation instead.
  • Rollback: enable Object Versioning on the bucket and restore previous object generations, or keep the previous build as an artifact and rsync it back; DNS can point back at the old host while investigating.

Conclusion

Google Cloud Storage makes a good static origin once a global load balancer with Cloud CDN sits in front: HTTPS with managed certificates, edge caching that respects per-file headers, custom security headers, negative caching and invalidation, all deployed without a single long-lived key thanks to Workload Identity Federation. Moving this documentation site off a single VM cut TTFB by 40–77% depending on region and removed a server from the patch list.

FAQ

Can Google Cloud Storage serve a static site on its own?

A public bucket can serve files, and bucket website configuration adds index and 404 pages, but only over HTTP for custom domains. For HTTPS on a custom domain, caching and security headers, put an external Application Load Balancer with Cloud CDN in front of the bucket.

How do clean URLs work on Cloud Storage?

The bucket's website configuration sets a main page suffix, usually index.html, which is served for directory-style requests through the load balancer. Requests without a trailing slash for a directory are redirected to add it.

How do I deploy without a long-lived service account key?

Use Workload Identity Federation with GitHub Actions. The workflow exchanges its OIDC token for short-lived Google credentials through google-github-actions/auth, with permission limited to the site's bucket and CDN invalidation.

What does Cloud CDN add?

Edge caching in Google's global network, cache keys and TTL control, signed URLs if needed, and cache invalidation. With the load balancer it also provides HTTPS with managed certificates, HTTP/3 and the option to attach Cloud Armor rules.

Filed underHow-tos