Skip to main content
Version: 57.2.0

GCP Hosting Architecture

This document explains the architecture and design decisions behind Terreno's Google Cloud Platform hosting setup for static web apps.

Overview

The Terreno monorepo hosts two static web applications on Google Cloud Storage (GCS) with Cloud CDN:

  • Demo app (demo/) — UI component showcase
  • Example frontend (example-frontend/) — Full-stack example with API integration

Both apps use the same architecture pattern optimized for single-page applications (SPAs) with client-side routing.

Architecture Diagram

┌─────────────────────────────────────────────────────────────┐
│ Client Browser │
└───────────────────────────┬─────────────────────────────────┘
│ HTTP Request

┌─────────────────────────────────────────────────────────────┐
│ Global Forwarding Rule (Static IP) │
│ (e.g., 34.120.xxx.xxx) │
└───────────────────────────┬─────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ HTTP Proxy │
└───────────────────────────┬─────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ URL Map │
│ (Routes to default backend bucket) │
└───────────────────────────┬─────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Backend Bucket (CDN-enabled) │
│ (e.g., terreno-demo-backend) │
└───────────────────────────┬─────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ GCS Bucket (Origin) │
│ (e.g., gs://flourish-terreno-terreno-demo) │
│ │
│ ├── index.html │
│ ├── _expo/ │
│ │ └── static/ │
│ │ ├── js/ │
│ │ ├── css/ │
│ │ └── media/ │
│ └── _previews/ │
│ └── pr-123/ │
│ └── index.html │
└─────────────────────────────────────────────────────────────┘

Components

1. GCS Bucket (Origin)

Purpose: Store static assets (HTML, JS, CSS, images).

Configuration:

  • Public access: allUsers:objectViewer IAM binding
  • Static website config: notFoundPage=index.html for SPA routing
  • No mainPageSuffix: Avoids GCS 301 redirects that break client routing

Key Feature: The notFoundPage directive serves index.html for any 404, enabling client-side routing. When a user visits /about directly, GCS returns index.html, and the React Router or Expo Router handles the route client-side.

2. Backend Bucket (CDN)

Purpose: Serve bucket content with Cloud CDN caching.

Configuration:

  • CDN enabled: Responses are cached at Google edge locations worldwide
  • Negative caching disabled: Prevents Cloud CDN from caching 404 responses, which would cause stale 404s for newly-deployed asset hashes
  • Linked to GCS bucket: Single bucket per backend

Performance: CDN reduces latency by serving cached content from edge locations closest to users.

3. URL Map

Purpose: Route incoming requests to the backend bucket.

Configuration:

  • Default service: Points to the backend bucket
  • Path-based routing: All paths (/*) route to the same backend

Extensibility: URL maps support path-based routing (e.g., /api/* → backend service, /* → frontend), but Terreno uses a single backend per app.

4. HTTP Proxy

Purpose: Terminate HTTP connections and forward to the URL map.

Configuration:

  • Protocol: HTTP (can be upgraded to HTTPS with certificates)
  • Forwarding: Passes requests to URL map

HTTPS Support: To enable HTTPS, create an SSL certificate and attach it to an HTTPS proxy instead.

5. Global Forwarding Rule (Static IP)

Purpose: Assign a static external IP address to the HTTP proxy.

Configuration:

  • IP address: Reserved global static IP (e.g., 34.120.xxx.xxx)
  • Port: 80 (HTTP)

DNS: Point your domain's A record to this IP for custom domains.

Design Decisions

Why Google Cloud Storage over Netlify?

Pros:

  • Cost: GCS + CDN is cheaper at scale (no bandwidth limits on free tier ending)
  • Control: Full control over caching, CDN configuration, and infrastructure
  • Integration: Native integration with other GCP services (Cloud Run backend, Cloud Build, etc.)
  • Flexibility: URL maps enable complex routing if needed

Cons:

  • Setup complexity: Requires manual GCP resource provisioning
  • No built-in forms/functions: Netlify provides these; GCS does not
  • DNS management: Requires separate DNS configuration (Netlify provides DNS)

Decision: GCS is better for Terreno because it's a showcase/demo project with minimal dynamic needs, and cost/control outweigh convenience.

Cache Strategy

File TypeCache-Control HeaderRationale
Hashed assets (e.g., main.abc123.js)public, max-age=31536000, immutableContent hash in filename ensures uniqueness; safe to cache forever
index.htmlno-cache, no-store, must-revalidateEntry point must never be cached to ensure users get the latest app shell

Implementation: Deployment workflows use gsutil -h to set headers during upload:

# Sync hashed assets with long cache
gsutil -m -h "Cache-Control:public, max-age=31536000, immutable" \
rsync -r -d -x '.*\.html$' dist/ gs://BUCKET/

# Upload index.html with no-cache
gsutil -h "Cache-Control:no-cache, no-store, must-revalidate" \
cp dist/index.html gs://BUCKET/index.html

SPA Routing Implementation

Challenge: SPAs use client-side routing (e.g., /about, /profile), but GCS has no such files.

Solution:

  1. GCS notFoundPage: Configure bucket with notFoundPage=index.html
  2. CDN behavior: When a request for /about results in a 404, GCS serves index.html
  3. Client-side routing: The app's router (Expo Router, React Router) reads the URL and renders the correct component

Why not mainPageSuffix?

  • mainPageSuffix=index.html causes GCS to 301 redirect /demo//demo/index.html, breaking client routing and creating duplicate URLs.
  • notFoundPage returns 200 with index.html content, preserving the original URL.

Preview Deployment Strategy

Goal: Deploy PR previews to unique URLs without polluting production.

Approach:

  • Path-based isolation: Previews deploy to _previews/pr-{number}/ within the same bucket
  • Bare route objects: Upload HTML files as bare objects (no .html extension) for SPA routing
  • Dynamic baseUrl: Inject expo.experiments.baseUrl = /_previews/pr-123 during build

Example:

gs://bucket/
├── index.html (production)
├── _expo/static/js/main.abc.js (production)
└── _previews/
├── pr-123 (bare object, serves preview index.html)
└── pr-123/
├── index.html (preview root)
└── _expo/static/js/main.def.js

CDN Limitation: The CDN's notFoundPage always serves the root index.html, not the preview's. To work around this:

  1. Upload preview routes as bare objects (e.g., _previews/pr-123/aboutabout.html uploaded without extension)
  2. Set Content-Type: text/html manually

Trade-off: This adds complexity but keeps previews in the same bucket, simplifying cleanup.

Deployment Flow

Production Deploy

  1. Trigger: Push to master branch with changes to demo/**, ui/**, example-frontend/**, rtk/**
  2. Build: GitHub Actions runs bun run export to generate static files
  3. Ensure negative caching disabled: Update backend bucket to prevent cached 404s
  4. Upload:
    • Sync new hashed assets (additive, no deletions) so old bundles remain available
    • Upload index.html with no-cache header, atomically switching to new bundle hashes
  5. Invalidate CDN: gcloud compute url-maps invalidate-cdn-cache --path "/*" clears all cached paths
  6. Cleanup: Sync with deletions (-d) to remove old assets no longer referenced

Preview Deploy

  1. Trigger: Open PR with changes to relevant paths
  2. Build: Inject baseUrl and run bun run export
  3. Upload:
    • Upload to _previews/pr-{number}/
    • Create bare route objects for SPA routing
  4. GitHub Deployment: Create deployment with preview URL
  5. Comment: Bot comments on PR with the preview link

Preview Cleanup

  1. Trigger: PR closed or merged
  2. Delete: gsutil -m rm -r gs://BUCKET/_previews/pr-{number}/
  3. Deactivate: Mark GitHub deployments as inactive

Security Considerations

Public Read Access

Risk: Anyone can access bucket contents.

Mitigation: Only public-facing demo apps are hosted. No sensitive data or credentials are stored in GCS.

Service Account Permissions

Principle of Least Privilege: The CI service account has:

  • roles/storage.objectAdmin on specific buckets (not project-wide)
  • roles/compute.loadBalancerAdmin for CDN cache invalidation only

Key Storage: GCP_SA_KEY secret is encrypted in GitHub Secrets and never logged.

Cost Optimization

Storage Costs

  • GCS Standard: ~$0.02/GB/month
  • Typical app size: ~5-10 MB
  • Monthly cost: < $0.01/month per app

CDN Costs

  • Cache hit: ~$0.08/GB
  • Cache miss: ~$0.12/GB (includes origin fetch)
  • Expected traffic: Low (demo/example apps)
  • Monthly cost: < $5/month for both apps

Optimization Strategies

  1. Maximize cache hits: Long cache headers on hashed assets
  2. Minimize origin fetches: CDN caches effectively
  3. Compress assets: Expo build automatically gzips/brotlis assets
  4. Delete old previews: Cleanup workflow prevents accumulation

Monitoring and Observability

Metrics

Monitor via GCP Console:

  • Storage: Bucket size, object count
  • CDN: Cache hit rate, request count, bandwidth
  • Costs: Cloud Billing dashboard

Alerts

Set up budget alerts in GCP:

gcloud billing budgets create \
--billing-account=BILLING_ACCOUNT_ID \
--display-name="Terreno Hosting Budget" \
--budget-amount=20USD \
--threshold-rule=percent=90

Logs

  • Access logs: Enable bucket access logging if needed
  • CDN logs: Enable Cloud CDN logging for request analysis

Future Improvements

HTTPS Support

  1. Reserve a domain (e.g., demo.terreno.dev)
  2. Create an SSL certificate:
    gcloud compute ssl-certificates create terreno-demo-cert \
    --domains=demo.terreno.dev \
    --global
  3. Create HTTPS proxy and forwarding rule
  4. Update DNS A record

Custom Domains

Point DNS A records to the static IPs:

demo.terreno.dev. A 34.120.xxx.xxx
example.terreno.dev. A 34.149.xxx.xxx

CDN Performance Tuning

  • Geo-routing: Use Cloud Load Balancing for region-specific backends
  • Compression: Enable brotli/gzip at CDN level
  • Pre-warming: Warm CDN cache after deployments

Multi-Region Redundancy

  • Replicate buckets to multiple regions
  • Use Cloud Load Balancing to route to nearest region