Skip to main content
Version: 57.2.0

Deploy web to GCS + CDN

Host a static Terreno web export on Google Cloud Storage with Cloud CDN.

Replace placeholders: $PROJECT_ID, $REGION, $WEB_BUCKET, $SITE_NAME, $DOMAIN.

Prerequisites

1. Build the web bundle

Set the API URL before export — it is inlined into the bundle:

cd example-frontend
EXPO_PUBLIC_API_URL=https://api.example.com bun run export

The export script is bun expo export --platform web (see example-frontend/package.json).

Output is in dist/.

2. Enable the Compute Engine API

Cloud CDN and its global load-balancing resources require the Compute Engine API:

gcloud services enable compute.googleapis.com --project="$PROJECT_ID"

3. Create the GCS bucket

gsutil mb -p "$PROJECT_ID" -l "$REGION" "gs://$WEB_BUCKET/"

4. Allow public object reads

Cloud CDN must be able to serve the bucket's objects to unauthenticated visitors:

gsutil iam ch allUsers:objectViewer "gs://$WEB_BUCKET"

Do not grant allUsers a write role such as objectAdmin.

5. Configure SPA routing

Client-side routes require index.html for unknown paths:

gsutil web set -e index.html "gs://$WEB_BUCKET"

Do not set MainPageSuffix — GCS 301 redirects break client-side routing.

6. Upload the build

gsutil -m -h "Cache-Control:public, max-age=31536000, immutable" \
rsync -r -d -x '.*\.html$' \
dist/ "gs://$WEB_BUCKET/"

gsutil -h "Cache-Control:no-cache, no-store, must-revalidate" \
cp dist/index.html "gs://$WEB_BUCKET/index.html"

7. Create HTTPS CDN resources

The load balancer serves the bucket over HTTPS only. A Google-managed certificate secures the domain:

# Backend bucket (CDN-enabled)
gcloud compute backend-buckets create "${SITE_NAME}-backend" \
--gcs-bucket-name="$WEB_BUCKET" \
--enable-cdn \
--no-negative-caching \
--project="$PROJECT_ID"

# URL map
gcloud compute url-maps create "${SITE_NAME}-url-map" \
--default-backend-bucket="${SITE_NAME}-backend" \
--project="$PROJECT_ID"

# Static IP
gcloud compute addresses create "${SITE_NAME}-ip" --global --project="$PROJECT_ID"

# Managed TLS certificate
gcloud compute ssl-certificates create "${SITE_NAME}-cert" \
--domains="$DOMAIN" \
--global \
--project="$PROJECT_ID"

# HTTPS proxy
gcloud compute target-https-proxies create "${SITE_NAME}-https-proxy" \
--url-map="${SITE_NAME}-url-map" \
--ssl-certificates="${SITE_NAME}-cert" \
--project="$PROJECT_ID"

# HTTPS forwarding rule
gcloud compute forwarding-rules create "${SITE_NAME}-https-forwarding-rule" \
--address="${SITE_NAME}-ip" \
--target-https-proxy="${SITE_NAME}-https-proxy" \
--global \
--ports=443 \
--project="$PROJECT_ID"

8. Redirect HTTP to HTTPS

Port 80 should redirect rather than serve the bundle. Without a listener there at all, http://$DOMAIN fails to connect instead of sending visitors to the secure URL.

gcloud exposes redirect behavior only through an imported spec, not through url-maps create flags:

gcloud compute url-maps import "${SITE_NAME}-http-redirect-url-map" \
--global --project="$PROJECT_ID" --source=/dev/stdin <<SPEC
name: ${SITE_NAME}-http-redirect-url-map
defaultUrlRedirect:
httpsRedirect: true
redirectResponseCode: MOVED_PERMANENTLY_DEFAULT
SPEC

gcloud compute target-http-proxies create "${SITE_NAME}-http-proxy" \
--url-map="${SITE_NAME}-http-redirect-url-map" \
--project="$PROJECT_ID"

gcloud compute forwarding-rules create "${SITE_NAME}-forwarding-rule" \
--address="${SITE_NAME}-ip" \
--target-http-proxy="${SITE_NAME}-http-proxy" \
--global \
--ports=80 \
--project="$PROJECT_ID"

Port 80 now only ever returns a 301; the bundle itself is served exclusively over TLS.

Or run the parameterized script, which provisions steps 7 and 8 in one pass:

./scripts/setup-gcs-hosting.sh \
--project "$PROJECT_ID" \
--site-name "$SITE_NAME" \
--bucket "$WEB_BUCKET" \
--domain "$DOMAIN"

9. Point DNS and wait for the certificate

gcloud compute addresses describe "${SITE_NAME}-ip" --global \
--project="$PROJECT_ID" --format='value(address)'

Create an A record for $DOMAIN pointing at that IP. Managed certificates only begin provisioning once DNS resolves to the load balancer, and can take up to about an hour to become active:

gcloud compute ssl-certificates describe "${SITE_NAME}-cert" --global \
--project="$PROJECT_ID" --format='value(managed.status)'

Wait for ACTIVE before sending traffic. Still PROVISIONING after an hour usually means the A record does not resolve to the reserved IP.

10. Invalidate cache after deploy

gcloud compute url-maps invalidate-cdn-cache "${SITE_NAME}-url-map" \
--path "/*" --async --project="$PROJECT_ID"

Static-only path

This guide hosts the expo export -p web output as static files. Server-rendered web (SSR, API routes) requires Expo SDK ≥ 55 — see Web SSR and admin SPA.