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
- gcloud CLI installed and authenticated
- Backend deployed and CORS configured — see Deploy backend to Cloud Run
- Build for web completed with the correct
EXPO_PUBLIC_API_URL
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.