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:objectViewerIAM binding - Static website config:
notFoundPage=index.htmlfor 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 Type | Cache-Control Header | Rationale |
|---|---|---|
Hashed assets (e.g., main.abc123.js) | public, max-age=31536000, immutable | Content hash in filename ensures uniqueness; safe to cache forever |
| index.html | no-cache, no-store, must-revalidate | Entry 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:
- GCS
notFoundPage: Configure bucket withnotFoundPage=index.html - CDN behavior: When a request for
/aboutresults in a 404, GCS servesindex.html - Client-side routing: The app's router (Expo Router, React Router) reads the URL and renders the correct component
Why not mainPageSuffix?
mainPageSuffix=index.htmlcauses GCS to 301 redirect/demo/→/demo/index.html, breaking client routing and creating duplicate URLs.notFoundPagereturns 200 withindex.htmlcontent, 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
.htmlextension) for SPA routing - Dynamic baseUrl: Inject
expo.experiments.baseUrl = /_previews/pr-123during 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:
- Upload preview routes as bare objects (e.g.,
_previews/pr-123/about→about.htmluploaded without extension) - Set
Content-Type: text/htmlmanually
Trade-off: This adds complexity but keeps previews in the same bucket, simplifying cleanup.
Deployment Flow
Production Deploy
- Trigger: Push to
masterbranch with changes todemo/**,ui/**,example-frontend/**,rtk/** - Build: GitHub Actions runs
bun run exportto generate static files - Ensure negative caching disabled: Update backend bucket to prevent cached 404s
- Upload:
- Sync new hashed assets (additive, no deletions) so old bundles remain available
- Upload
index.htmlwith no-cache header, atomically switching to new bundle hashes
- Invalidate CDN:
gcloud compute url-maps invalidate-cdn-cache --path "/*"clears all cached paths - Cleanup: Sync with deletions (
-d) to remove old assets no longer referenced
Preview Deploy
- Trigger: Open PR with changes to relevant paths
- Build: Inject
baseUrland runbun run export - Upload:
- Upload to
_previews/pr-{number}/ - Create bare route objects for SPA routing
- Upload to
- GitHub Deployment: Create deployment with preview URL
- Comment: Bot comments on PR with the preview link
Preview Cleanup
- Trigger: PR closed or merged
- Delete:
gsutil -m rm -r gs://BUCKET/_previews/pr-{number}/ - 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.objectAdminon specific buckets (not project-wide)roles/compute.loadBalancerAdminfor 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
- Maximize cache hits: Long cache headers on hashed assets
- Minimize origin fetches: CDN caches effectively
- Compress assets: Expo build automatically gzips/brotlis assets
- 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
- Reserve a domain (e.g.,
demo.terreno.dev) - Create an SSL certificate:
gcloud compute ssl-certificates create terreno-demo-cert \--domains=demo.terreno.dev \--global
- Create HTTPS proxy and forwarding rule
- 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
Related Documentation
- Deploy to GCP — Step-by-step deployment guide
- Environment Variables Reference — Configure runtime settings
- GCP Documentation: Hosting a Static Website — Official GCS guide