From 96fc28f55c068146e6486f210057597ace761965 Mon Sep 17 00:00:00 2001 From: Jan Riethmayer Date: Mon, 25 May 2026 20:41:24 +0200 Subject: [PATCH] feat: deploy to Cloud Run with keyless CI/CD, in parallel with Vercel Run Eureka on Google Cloud Run (project eureka-362814) alongside the existing Vercel deployment, both backed by the same Supabase database. This is the migration path: verify GCP under real traffic, then flip DNS and retire Vercel. - next.config: standalone output + pinned outputFileTracingRoot - Dockerfile + .dockerignore: multi-stage Next.js standalone image (node:24-slim, non-root) - cloudbuild.yaml: build with NEXT_PUBLIC_* build args and the Sentry token pulled from Secret Manager (token confined to the discarded builder stage) - deploy/deploy.sh: idempotent bootstrap + build + deploy; --deploy-only for CI - .github/workflows/deploy-gcp.yml: auto-deploy on push to master via Workload Identity Federation (no service-account key stored in GitHub) - docs/deployment: illustrated mini-site (overview, pipeline, security, tradeoffs) with live mermaid.js diagrams Vercel-safe: output:"standalone" plus the new files are inert under Vercel's build pipeline, so a single codebase serves both deployments. --- .dockerignore | 15 ++ .github/workflows/deploy-gcp.yml | 43 +++++ Dockerfile | 52 ++++++ README.md | 12 ++ cloudbuild.yaml | 57 ++++++ deploy/README.md | 85 +++++++++ deploy/deploy.sh | 118 +++++++++++++ docs/deployment/assets/mermaid-init.js | 57 ++++++ docs/deployment/assets/styles.css | 231 +++++++++++++++++++++++++ docs/deployment/index.html | 145 ++++++++++++++++ docs/deployment/pipeline.html | 151 ++++++++++++++++ docs/deployment/security.html | 163 +++++++++++++++++ docs/deployment/tradeoffs.html | 109 ++++++++++++ next.config.mjs | 11 ++ 14 files changed, 1249 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy-gcp.yml create mode 100644 Dockerfile create mode 100644 cloudbuild.yaml create mode 100644 deploy/README.md create mode 100755 deploy/deploy.sh create mode 100644 docs/deployment/assets/mermaid-init.js create mode 100644 docs/deployment/assets/styles.css create mode 100644 docs/deployment/index.html create mode 100644 docs/deployment/pipeline.html create mode 100644 docs/deployment/security.html create mode 100644 docs/deployment/tradeoffs.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc93ded --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Build context hygiene — keep the image build fast and free of host artifacts. +.git +.gitignore +node_modules +.next +.vercel +.yarn/cache +.yarn/install-state.gz +.env +.env.* +coverage +.DS_Store +*.log +.claude +README.md diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml new file mode 100644 index 0000000..80f5a47 --- /dev/null +++ b/.github/workflows/deploy-gcp.yml @@ -0,0 +1,43 @@ +name: Deploy to Cloud Run + +# Auto-deploys the app to GCP Cloud Run on every push to master, in parallel +# with the existing Vercel deployment. Auth is keyless via Workload Identity +# Federation (no service-account key stored in GitHub). The build itself +# (Cloud Build running `next build`) is the gate: a broken build produces no +# new revision, so the currently-serving revision stays up. + +on: + push: + branches: [master] + paths-ignore: + - "**.md" + - "docs/**" + workflow_dispatch: {} + +permissions: + contents: read + id-token: write # required to mint the OIDC token for WIF + +concurrency: + group: deploy-gcp + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud (Workload Identity Federation) + uses: google-github-actions/auth@v2 + with: + project_id: eureka-362814 + workload_identity_provider: ${{ vars.WIF_PROVIDER }} + service_account: ${{ vars.WIF_SERVICE_ACCOUNT }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Build & deploy to Cloud Run + run: bash deploy/deploy.sh --deploy-only diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7219f5c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 + +# Next.js 16 standalone server for Cloud Run. +# Mirrors the Vercel runtime: Node 24, Supabase over the network, Sentry source maps. + +# ---- Base ---------------------------------------------------------------- +FROM node:24-slim AS base +ENV NEXT_TELEMETRY_DISABLED=1 +WORKDIR /app + +# ---- Dependencies -------------------------------------------------------- +# Yarn 4 is vendored in .yarn/releases and pinned via packageManager; invoke it +# directly so the build never depends on corepack downloading a release. +FROM base AS deps +COPY package.json yarn.lock .yarnrc.yml ./ +COPY .yarn/ ./.yarn/ +RUN node .yarn/releases/yarn-4.9.1.cjs install --immutable + +# ---- Builder ------------------------------------------------------------- +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# NEXT_PUBLIC_* are inlined into the client bundle at build time, so they must +# be present here (not just at runtime). +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY +ARG NEXT_PUBLIC_SENTRY_DSN +# Build-time only: @sentry/nextjs uses this to upload source maps. The builder +# stage is discarded, so the token never lands in the final image. +ARG SENTRY_AUTH_TOKEN +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \ + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=$NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY \ + NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN \ + SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN \ + CI=true \ + NODE_ENV=production +RUN node .yarn/releases/yarn-4.9.1.cjs build + +# ---- Runner -------------------------------------------------------------- +FROM base AS runner +ENV NODE_ENV=production \ + PORT=8080 \ + HOSTNAME=0.0.0.0 +# .next/standalone bundles a minimal server + node_modules; static assets and +# public/ are copied alongside it as Next expects. +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/public ./public +USER node +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/README.md b/README.md index bd85207..5b59684 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,15 @@ You start the game with `yarn start` and open `localhost:3000`. # Documentation ![Documentation](Eureka.png "Excalidraw documentation") + +# Deployment + +Eureka deploys to **Google Cloud Run** (project `eureka-362814`) on every push to +`master`, in parallel with Vercel. The full flow — pipeline, keyless security +model (Workload Identity Federation), and tradeoffs — is documented as an +illustrated mini-site with live diagrams: + +➡️ **[`docs/deployment/`](docs/deployment/index.html)** — open `index.html` in a browser + +Quick reference: build & deploy with [`deploy/deploy.sh`](deploy/deploy.sh); see +[`deploy/README.md`](deploy/README.md) for the runbook. diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000..c0e027f --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,57 @@ +# Cloud Build: build the Next.js standalone image and push it to Artifact Registry. +# +# NEXT_PUBLIC_* values are inlined into the client bundle at build time, so they +# are pulled from Secret Manager here and passed as --build-arg (not as runtime +# env). SENTRY_AUTH_TOKEN is build-only (source-map upload) and never lands in +# the final image because the builder stage is discarded. +# +# Submit with: gcloud builds submit --config cloudbuild.yaml +# (see deploy/deploy.sh for the full enable→build→deploy flow) + +substitutions: + _REGION: europe-west1 + _REPO: eureka + _SERVICE: eureka-web + +availableSecrets: + secretManager: + - versionName: projects/$PROJECT_ID/secrets/eureka-supabase-url/versions/latest + env: NEXT_PUBLIC_SUPABASE_URL + - versionName: projects/$PROJECT_ID/secrets/eureka-supabase-publishable-key/versions/latest + env: NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY + - versionName: projects/$PROJECT_ID/secrets/eureka-sentry-dsn/versions/latest + env: NEXT_PUBLIC_SENTRY_DSN + - versionName: projects/$PROJECT_ID/secrets/eureka-sentry-auth-token/versions/latest + env: SENTRY_AUTH_TOKEN + +steps: + - id: build + name: gcr.io/cloud-builders/docker + entrypoint: bash + secretEnv: + - NEXT_PUBLIC_SUPABASE_URL + - NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY + - NEXT_PUBLIC_SENTRY_DSN + - SENTRY_AUTH_TOKEN + args: + - -c + - | + IMAGE="$_REGION-docker.pkg.dev/$PROJECT_ID/$_REPO/$_SERVICE" + docker build \ + --build-arg NEXT_PUBLIC_SUPABASE_URL="$$NEXT_PUBLIC_SUPABASE_URL" \ + --build-arg NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY="$$NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY" \ + --build-arg NEXT_PUBLIC_SENTRY_DSN="$$NEXT_PUBLIC_SENTRY_DSN" \ + --build-arg SENTRY_AUTH_TOKEN="$$SENTRY_AUTH_TOKEN" \ + -t "$$IMAGE:$BUILD_ID" \ + -t "$$IMAGE:latest" \ + . + + - id: push + name: gcr.io/cloud-builders/docker + args: ["push", "--all-tags", "$_REGION-docker.pkg.dev/$PROJECT_ID/$_REPO/$_SERVICE"] + +options: + logging: CLOUD_LOGGING_ONLY + machineType: E2_HIGHCPU_8 + +timeout: 1800s diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..0685a8a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,85 @@ +# GCP deployment (Cloud Run) + +> 📊 **Illustrated docs with live diagrams:** [`../docs/deployment/index.html`](../docs/deployment/index.html) +> — overview, pipeline, the keyless security model, and tradeoffs. This file is the terse runbook. + +Eureka runs on **Google Cloud Run** in project `eureka-362814`, in parallel with +the existing Vercel deployment. Both serve the *same* code and talk to the *same* +Supabase database — Cloud Run only replaces the hosting/runtime layer. + +- **Service:** `eureka-web` (region `europe-west1`) +- **Live URL:** https://eureka-web-369713805962.europe-west1.run.app +- **Image:** `europe-west1-docker.pkg.dev/eureka-362814/eureka/eureka-web:latest` +- **Database:** Supabase (unchanged — `lthqyqlislwikgoxmttn.supabase.co`) + +## Architecture + +``` +GitHub repo ──> Cloud Build (cloudbuild.yaml) ──> Artifact Registry ──> Cloud Run + │ │ + └─ build args from Secret Manager runtime secrets / env +``` + +- The app is built with Next.js `output: "standalone"` (see `next.config.mjs`) + and packaged by the root `Dockerfile` (Node 24, multi-stage, runs as non-root). +- `NEXT_PUBLIC_*` values are inlined at **build time**, so Cloud Build pulls them + from Secret Manager and passes them as `--build-arg`. +- `SENTRY_AUTH_TOKEN` is build-only (source-map upload); the builder stage is + discarded so it never lands in the published image. +- At **runtime**, Cloud Run injects `SENTRY_DSN` from Secret Manager and sets + `NODE_ENV=production`. `PORT` (8080) is provided by Cloud Run. + +## Secret Manager (the source of truth for env, replacing Vercel's env panel) + +| Secret | Used as | When | +|---|---|---| +| `eureka-supabase-url` | `NEXT_PUBLIC_SUPABASE_URL` | build | +| `eureka-supabase-publishable-key` | `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | build | +| `eureka-sentry-dsn` | `NEXT_PUBLIC_SENTRY_DSN` (build) + `SENTRY_DSN` (runtime) | build + runtime | +| `eureka-sentry-auth-token` | `SENTRY_AUTH_TOKEN` (source-map upload) | build | + +Both the Cloud Build SA (`…@cloudbuild`) and the Compute Engine default SA +(`…-compute@developer`, used at build and as the Cloud Run runtime identity) have +`roles/secretmanager.secretAccessor` on these secrets. + +## Deploy / redeploy + +```sh +# one-liner (idempotent): enable APIs, ensure repo, build, push, deploy +deploy/deploy.sh + +# to (re)seed or rotate secrets from your shell first: +SUPABASE_URL=… SUPABASE_PUBLISHABLE_KEY=… SENTRY_DSN=… SENTRY_AUTH_TOKEN=… \ + deploy/deploy.sh --seed +``` + +Or manually: + +```sh +gcloud builds submit --config cloudbuild.yaml \ + --substitutions _REGION=europe-west1,_REPO=eureka,_SERVICE=eureka-web + +gcloud run deploy eureka-web \ + --image europe-west1-docker.pkg.dev/eureka-362814/eureka/eureka-web:latest \ + --region europe-west1 --allow-unauthenticated --port 8080 \ + --cpu 1 --memory 512Mi --min-instances 0 --max-instances 5 \ + --set-env-vars NODE_ENV=production,NEXT_TELEMETRY_DISABLED=1 \ + --set-secrets SENTRY_DSN=eureka-sentry-dsn:latest +``` + +## Verified on first deploy (2026-05-25) + +- `/`, `/play`, `/highscores`, `/next-level`, `/game-over`, `/paused` → 200 +- `GET /api/highscores` → live Supabase read from Cloud Run +- `POST /api/game` → 201, row written to Supabase (test row removed afterward) +- Static assets (favicon, `_next/static`) served; Sentry tunnel `/monitoring` wired (POST-only) +- Cold-start `/` ≈ 0.44s, warm `/highscores` ≈ 0.13s + +## Remaining for the full Vercel → GCP cutover + +1. Commit these files (`Dockerfile`, `.dockerignore`, `cloudbuild.yaml`, `deploy/`, + `next.config.mjs` change) — they are Vercel-safe, so one codebase serves both. +2. (Optional) Add a Cloud Build trigger on push to `master` for continuous deploy. +3. Map the production custom domain to the Cloud Run service (Cloud Run domain + mapping or a load balancer), then flip DNS. +4. Once GCP has served production traffic cleanly, retire the Vercel project. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..9ee2836 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Deploy Eureka to Cloud Run in project eureka-362814. +# +# Idempotent: enables APIs, creates the Artifact Registry repo and Secret +# Manager secrets if missing, builds + pushes the image via Cloud Build, then +# deploys the Cloud Run service. Re-run any time to ship a new revision. +# +# Secret values are read from the environment ONCE to seed Secret Manager; they +# are never committed. Required only the first time (or to rotate a secret): +# +# SUPABASE_URL=... (NEXT_PUBLIC_SUPABASE_URL) +# SUPABASE_PUBLISHABLE_KEY=... (NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) +# SENTRY_DSN=... (public client DSN) +# SENTRY_AUTH_TOKEN=... (org:ci token, source-map upload only) +# +# Usage: +# deploy/deploy.sh # full path: bootstrap (idempotent) + build + deploy +# deploy/deploy.sh --seed # also create/update secrets from the env vars above +# deploy/deploy.sh --deploy-only # CI path: skip bootstrap, only build + deploy +# +set -euo pipefail + +PROJECT_ID="${PROJECT_ID:-eureka-362814}" +REGION="${REGION:-europe-west1}" +REPO="${REPO:-eureka}" +SERVICE="${SERVICE:-eureka-web}" + +GCLOUD="${GCLOUD:-$HOME/.google-cloud-sdk/bin/gcloud}" +[ -x "$GCLOUD" ] || GCLOUD="gcloud" + +SEED=false +DEPLOY_ONLY=false +for arg in "$@"; do + case "$arg" in + --seed) SEED=true ;; + --deploy-only) DEPLOY_ONLY=true ;; # CI: bootstrap already done, just ship + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +echo "▶ project=$PROJECT_ID region=$REGION service=$SERVICE" +"$GCLOUD" config set project "$PROJECT_ID" >/dev/null +# Pin the quota/billing project so client-library calls (e.g. the Cloud Build +# source upload) are attributed to this project rather than defaulting to an +# empty/unexpected consumer — required when running as an impersonated/federated +# service account (CI via Workload Identity, or local impersonation). +"$GCLOUD" config set billing/quota_project "$PROJECT_ID" >/dev/null 2>&1 || true + +if ! $DEPLOY_ONLY; then + echo "▶ enabling APIs" + "$GCLOUD" services enable \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + secretmanager.googleapis.com \ + cloudresourcemanager.googleapis.com + + echo "▶ ensuring Artifact Registry repo '$REPO'" + "$GCLOUD" artifacts repositories describe "$REPO" --location "$REGION" >/dev/null 2>&1 || \ + "$GCLOUD" artifacts repositories create "$REPO" \ + --repository-format docker --location "$REGION" \ + --description "Eureka container images" +fi + +# --- secrets -------------------------------------------------------------- +upsert_secret() { # name, value + local name="$1" value="$2" + if [ -z "$value" ]; then echo " ! $name: no value provided, skipping"; return; fi + if "$GCLOUD" secrets describe "$name" >/dev/null 2>&1; then + printf '%s' "$value" | "$GCLOUD" secrets versions add "$name" --data-file=- >/dev/null + echo " ↻ $name: new version added" + else + printf '%s' "$value" | "$GCLOUD" secrets create "$name" --replication-policy automatic --data-file=- >/dev/null + echo " + $name: created" + fi +} + +if $SEED; then + echo "▶ seeding secrets from environment" + upsert_secret eureka-supabase-url "${SUPABASE_URL:-}" + upsert_secret eureka-supabase-publishable-key "${SUPABASE_PUBLISHABLE_KEY:-}" + upsert_secret eureka-sentry-dsn "${SENTRY_DSN:-}" + upsert_secret eureka-sentry-auth-token "${SENTRY_AUTH_TOKEN:-}" +fi + +# --- IAM: let Cloud Build read secrets at build time ---------------------- +if ! $DEPLOY_ONLY; then + PROJECT_NUMBER="$("$GCLOUD" projects describe "$PROJECT_ID" --format='value(projectNumber)')" + CLOUDBUILD_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" + for s in eureka-supabase-url eureka-supabase-publishable-key eureka-sentry-dsn eureka-sentry-auth-token; do + "$GCLOUD" secrets add-iam-policy-binding "$s" \ + --member "serviceAccount:${CLOUDBUILD_SA}" \ + --role roles/secretmanager.secretAccessor >/dev/null 2>&1 || true + done +fi + +# --- build + push --------------------------------------------------------- +echo "▶ building + pushing image via Cloud Build" +"$GCLOUD" builds submit --config cloudbuild.yaml \ + --substitutions "_REGION=${REGION},_REPO=${REPO},_SERVICE=${SERVICE}" + +# --- deploy --------------------------------------------------------------- +IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${SERVICE}:latest" +echo "▶ deploying $SERVICE" +"$GCLOUD" run deploy "$SERVICE" \ + --image "$IMAGE" \ + --region "$REGION" \ + --platform managed \ + --allow-unauthenticated \ + --port 8080 \ + --cpu 1 --memory 512Mi \ + --min-instances 0 --max-instances 5 \ + --set-env-vars NODE_ENV=production,NEXT_TELEMETRY_DISABLED=1 \ + --set-secrets "SENTRY_DSN=eureka-sentry-dsn:latest" + +URL="$("$GCLOUD" run services describe "$SERVICE" --region "$REGION" --format='value(status.url)')" +echo "✅ deployed: $URL" diff --git a/docs/deployment/assets/mermaid-init.js b/docs/deployment/assets/mermaid-init.js new file mode 100644 index 0000000..49f05d2 --- /dev/null +++ b/docs/deployment/assets/mermaid-init.js @@ -0,0 +1,57 @@ +// Mermaid 11 (ESM) initialised with a custom theme that matches the +// "deployment console / blueprint" palette used across these docs. +import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'; + +mermaid.initialize({ + startOnLoad: true, + securityLevel: 'loose', + theme: 'base', + fontFamily: "'IBM Plex Mono', monospace", + themeVariables: { + darkMode: true, + background: '#0e131c', + fontFamily: "'IBM Plex Mono', monospace", + fontSize: '14px', + + primaryColor: '#18212f', + primaryBorderColor: '#c9f24e', + primaryTextColor: '#e9eef5', + secondaryColor: '#121927', + secondaryBorderColor: '#5fd5ee', + secondaryTextColor: '#e9eef5', + tertiaryColor: '#0e131c', + tertiaryBorderColor: '#7e8da0', + tertiaryTextColor: '#b9c4d2', + + lineColor: '#5fd5ee', + textColor: '#b9c4d2', + mainBkg: '#18212f', + nodeBorder: '#c9f24e', + clusterBkg: 'rgba(95,213,238,0.05)', + clusterBorder: 'rgba(150,180,220,0.25)', + titleColor: '#e9eef5', + edgeLabelBackground: '#0a0e14', + + // sequence diagrams + actorBkg: '#18212f', + actorBorder: '#b69cff', + actorTextColor: '#e9eef5', + actorLineColor: '#7e8da0', + signalColor: '#5fd5ee', + signalTextColor: '#b9c4d2', + labelBoxBkgColor: '#121927', + labelBoxBorderColor: '#5fd5ee', + labelTextColor: '#e9eef5', + loopTextColor: '#b9c4d2', + noteBkgColor: 'rgba(255,180,84,0.12)', + noteBorderColor: '#ffb454', + noteTextColor: '#e9eef5', + activationBkgColor: '#18212f', + activationBorderColor: '#c9f24e', + + // state / flowchart accents + nodeTextColor: '#e9eef5', + }, + flowchart: { curve: 'basis', htmlLabels: true, padding: 14 }, + sequence: { actorMargin: 46, messageAlign: 'center', mirrorActors: false, useMaxWidth: true }, +}); diff --git a/docs/deployment/assets/styles.css b/docs/deployment/assets/styles.css new file mode 100644 index 0000000..050bd30 --- /dev/null +++ b/docs/deployment/assets/styles.css @@ -0,0 +1,231 @@ +/* =========================================================================== + Eureka · Deployment docs — "deployment console / blueprint" design system + Dark, technical, editorial. Fraunces display · Hanken Grotesk body · IBM Plex Mono. + =========================================================================== */ + +@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..900;1,9..144,400..700&family=Hanken+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap'); + +:root { + --ink: #0a0e14; + --ink-1: #0e131c; + --ink-2: #121927; + --ink-3: #18212f; + --line: rgba(150, 180, 220, 0.12); + --line-2: rgba(150, 180, 220, 0.20); + + --text: #e9eef5; + --text-soft: #b9c4d2; + --muted: #7e8da0; + + --signal: #c9f24e; /* live / go / primary accent */ + --signal-dim: #97b833; + --amber: #ffb454; /* build / caution */ + --cyan: #5fd5ee; /* data flow / links */ + --violet: #b69cff; /* identity / tokens */ + --rose: #ff7a85; /* risk / keys / danger */ + + --display: 'Fraunces', Georgia, serif; + --body: 'Hanken Grotesk', system-ui, sans-serif; + --mono: 'IBM Plex Mono', ui-monospace, monospace; + + --maxw: 1080px; + --radius: 14px; + --shadow: 0 20px 60px -20px rgba(0,0,0,0.7); +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + background: var(--ink); + color: var(--text); + font-family: var(--body); + font-size: 17px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + position: relative; + overflow-x: hidden; +} + +/* Blueprint grid + atmospheric glow + grain ------------------------------ */ +body::before { + content: ""; + position: fixed; inset: 0; z-index: -2; + background: + radial-gradient(900px 600px at 78% -8%, rgba(201,242,78,0.10), transparent 60%), + radial-gradient(800px 700px at 8% 102%, rgba(95,213,238,0.08), transparent 55%), + linear-gradient(var(--line) 1px, transparent 1px), + linear-gradient(90deg, var(--line) 1px, transparent 1px); + background-size: auto, auto, 46px 46px, 46px 46px; + background-position: 0 0, 0 0, -1px -1px, -1px -1px; +} +body::after { + content: ""; + position: fixed; inset: 0; z-index: -1; pointer-events: none; + opacity: 0.035; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* Layout ------------------------------------------------------------------ */ +.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 28px; } + +/* Nav --------------------------------------------------------------------- */ +.nav { + position: sticky; top: 0; z-index: 50; + backdrop-filter: blur(14px); + background: linear-gradient(180deg, rgba(10,14,20,0.92), rgba(10,14,20,0.62)); + border-bottom: 1px solid var(--line); +} +.nav .wrap { display: flex; align-items: center; gap: 24px; height: 64px; } +.brand { + font-family: var(--mono); font-size: 13px; letter-spacing: 0.18em; + text-transform: uppercase; color: var(--text); text-decoration: none; + display: flex; align-items: center; gap: 10px; white-space: nowrap; +} +.brand .dot { + width: 9px; height: 9px; border-radius: 50%; background: var(--signal); + box-shadow: 0 0 14px var(--signal); animation: pulse 2.6s ease-in-out infinite; +} +@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} } +.nav-links { display: flex; gap: 4px; margin-left: auto; flex-wrap: wrap; } +.nav-links a { + font-family: var(--mono); font-size: 12.5px; letter-spacing: 0.04em; + color: var(--muted); text-decoration: none; padding: 7px 12px; border-radius: 8px; + transition: color .2s, background .2s; +} +.nav-links a:hover { color: var(--text); background: var(--ink-2); } +.nav-links a.active { color: var(--ink); background: var(--signal); font-weight: 600; } + +/* Hero -------------------------------------------------------------------- */ +.hero { padding: 86px 0 48px; } +.eyebrow { + font-family: var(--mono); font-size: 12.5px; letter-spacing: 0.22em; + text-transform: uppercase; color: var(--signal); margin: 0 0 20px; + display: flex; align-items: center; gap: 12px; +} +.eyebrow::before { content: ""; width: 38px; height: 1px; background: var(--signal); } +h1 { + font-family: var(--display); font-weight: 600; font-size: clamp(40px, 7vw, 78px); + line-height: 0.98; letter-spacing: -0.02em; margin: 0 0 22px; +} +h1 em { font-style: italic; color: var(--signal); font-weight: 500; } +.lede { font-size: clamp(18px, 2.4vw, 22px); color: var(--text-soft); max-width: 60ch; margin: 0 0 34px; } + +/* Section headings -------------------------------------------------------- */ +.section { padding: 52px 0; border-top: 1px solid var(--line); } +.kicker { + font-family: var(--mono); font-size: 12px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--cyan); margin: 0 0 14px; +} +h2 { font-family: var(--display); font-weight: 600; font-size: clamp(28px, 4vw, 42px); letter-spacing: -0.015em; margin: 0 0 18px; line-height: 1.05; } +h3 { font-family: var(--display); font-weight: 600; font-size: 23px; margin: 34px 0 12px; letter-spacing: -0.01em; } +h4 { font-family: var(--mono); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin: 26px 0 10px; } +p { margin: 0 0 16px; color: var(--text-soft); } +a { color: var(--cyan); text-underline-offset: 3px; text-decoration-thickness: 1px; } +strong { color: var(--text); font-weight: 600; } +ul, ol { color: var(--text-soft); padding-left: 22px; } +li { margin: 7px 0; } +li::marker { color: var(--signal-dim); } + +/* Cards & grid ------------------------------------------------------------ */ +.grid { display: grid; gap: 18px; } +.grid.cols-2 { grid-template-columns: repeat(2, 1fr); } +.grid.cols-3 { grid-template-columns: repeat(3, 1fr); } +@media (max-width: 820px) { .grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; } } + +.card { + background: linear-gradient(160deg, var(--ink-2), var(--ink-1)); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 26px; position: relative; overflow: hidden; +} +.card::before { + content: ""; position: absolute; inset: 0 0 auto 0; height: 2px; + background: linear-gradient(90deg, var(--accent, var(--signal)), transparent); +} +.card .num { font-family: var(--mono); font-size: 12px; color: var(--accent, var(--signal)); letter-spacing: 0.1em; } +.card h3 { margin: 8px 0 8px; font-size: 20px; } +.card p { font-size: 15.5px; margin: 0; } +.card.cyan { --accent: var(--cyan); } +.card.amber { --accent: var(--amber); } +.card.violet { --accent: var(--violet); } +.card.rose { --accent: var(--rose); } + +/* Diagram panel ----------------------------------------------------------- */ +.diagram { + background: var(--ink-1); border: 1px solid var(--line-2); + border-radius: var(--radius); padding: 14px; margin: 26px 0; + box-shadow: var(--shadow); position: relative; +} +.diagram .cap { + font-family: var(--mono); font-size: 11.5px; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--muted); padding: 8px 12px 12px; display: flex; align-items: center; gap: 10px; +} +.diagram .cap::before { content: "◆"; color: var(--cyan); font-size: 9px; } +.mermaid { display: flex; justify-content: center; padding: 8px; } + +/* Callouts ---------------------------------------------------------------- */ +.note { + border-left: 3px solid var(--accent, var(--cyan)); + background: color-mix(in srgb, var(--accent, var(--cyan)) 8%, var(--ink-1)); + padding: 16px 20px; border-radius: 0 10px 10px 0; margin: 22px 0; +} +.note .tag { font-family: var(--mono); font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent, var(--cyan)); display: block; margin-bottom: 6px; } +.note p { margin: 0; color: var(--text-soft); } +.note.go { --accent: var(--signal); } +.note.warn { --accent: var(--amber); } +.note.risk { --accent: var(--rose); } +.note.ident { --accent: var(--violet); } + +/* Code -------------------------------------------------------------------- */ +code { font-family: var(--mono); font-size: 0.86em; background: var(--ink-3); padding: 2px 6px; border-radius: 5px; color: var(--signal); border: 1px solid var(--line); } +pre { + font-family: var(--mono); font-size: 13.5px; line-height: 1.6; + background: var(--ink-1); border: 1px solid var(--line); border-left: 3px solid var(--cyan); + border-radius: 10px; padding: 18px 20px; overflow-x: auto; color: var(--text-soft); margin: 18px 0; +} +pre code { background: none; border: none; padding: 0; color: inherit; font-size: inherit; } + +/* Tables ------------------------------------------------------------------ */ +.tablewrap { overflow-x: auto; margin: 22px 0; border: 1px solid var(--line); border-radius: var(--radius); } +table { width: 100%; border-collapse: collapse; font-size: 14.5px; min-width: 560px; } +th, td { text-align: left; padding: 13px 16px; border-bottom: 1px solid var(--line); vertical-align: top; } +thead th { font-family: var(--mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); background: var(--ink-2); } +tbody tr:hover { background: var(--ink-1); } +td strong { color: var(--text); } +.pill { font-family: var(--mono); font-size: 11px; padding: 2px 9px; border-radius: 20px; white-space: nowrap; border: 1px solid var(--line-2); } +.pill.good { color: var(--signal); border-color: var(--signal-dim); } +.pill.bad { color: var(--rose); border-color: var(--rose); } +.pill.mid { color: var(--amber); border-color: var(--amber); } + +/* Step list (numbered flow) ---------------------------------------------- */ +.steps { counter-reset: step; list-style: none; padding: 0; margin: 24px 0; } +.steps li { + counter-increment: step; position: relative; padding: 0 0 24px 56px; margin: 0; + border-left: 1px solid var(--line); margin-left: 18px; +} +.steps li:last-child { border-left-color: transparent; padding-bottom: 0; } +.steps li::before { + content: counter(step, decimal-leading-zero); + position: absolute; left: -18px; top: -4px; width: 36px; height: 36px; + background: var(--ink-2); border: 1px solid var(--line-2); border-radius: 50%; + display: grid; place-items: center; font-family: var(--mono); font-size: 12px; color: var(--signal); +} +.steps li strong { display: block; font-family: var(--body); font-size: 16.5px; color: var(--text); margin-bottom: 3px; } + +/* Footer ------------------------------------------------------------------ */ +.foot { border-top: 1px solid var(--line); padding: 40px 0 70px; margin-top: 40px; color: var(--muted); font-family: var(--mono); font-size: 12.5px; } +.foot a { color: var(--text-soft); } +.foot .row { display: flex; flex-wrap: wrap; gap: 18px 28px; align-items: center; } + +/* Page-load reveal -------------------------------------------------------- */ +.reveal { opacity: 0; transform: translateY(14px); animation: rise .7s cubic-bezier(.2,.7,.2,1) forwards; } +@keyframes rise { to { opacity: 1; transform: none; } } +.reveal:nth-child(2){animation-delay:.06s}.reveal:nth-child(3){animation-delay:.12s} +.reveal:nth-child(4){animation-delay:.18s}.reveal:nth-child(5){animation-delay:.24s} + +@media (prefers-reduced-motion: reduce) { + *, *::before { animation: none !important; scroll-behavior: auto; } +} diff --git a/docs/deployment/index.html b/docs/deployment/index.html new file mode 100644 index 0000000..a430514 --- /dev/null +++ b/docs/deployment/index.html @@ -0,0 +1,145 @@ + + + + + + Eureka · Deployment — Overview + + + + + + +
+

Cloud Run · project eureka-362814

+

Eureka ships to
two clouds at once.

+

Every push to master deploys the same Next.js app to Google Cloud Run and Vercel in parallel — both backed by the same Supabase database. This is the migration path: prove GCP under real traffic, then flip DNS and retire Vercel. No big-bang cutover, no downtime.

+
+ +
+

The big picture

+

One repo, two deploys, one database

+

A merge to master fans out. Vercel’s GitHub integration rebuilds and serves on its domain. In parallel, a GitHub Actions workflow authenticates to Google Cloud without any stored key, builds a container, and rolls it onto Cloud Run. Players hit whichever front door DNS points at — the data they read and write lives in the same place.

+ +
+
System context — push to live
+
+flowchart LR
+  dev([git push to master]):::evt
+  subgraph GH["GitHub · riethmayer/eureka"]
+    repo[("repository")]
+    gha["Actions
deploy-gcp.yml"] + end + vercel["Vercel
auto-deploy"]:::vercel + subgraph GCP["Google Cloud · eureka-362814"] + cb["Cloud Build"]:::amber + ar[("Artifact
Registry")] + run["Cloud Run
eureka-web"]:::run + end + supa[("Supabase
Postgres + RLS")]:::data + users((Players)):::user + + dev --> repo + repo --> vercel + repo --> gha + gha -->|"keyless WIF auth"| cb + cb --> ar --> run + vercel -. reads/writes .-> supa + run -. reads/writes .-> supa + users --> vercel + users --> run + + classDef evt fill:#0e131c,stroke:#c9f24e,color:#c9f24e; + classDef run fill:#18212f,stroke:#c9f24e,color:#e9eef5,stroke-width:2px; + classDef vercel fill:#121927,stroke:#7e8da0,color:#b9c4d2,stroke-dasharray:4 3; + classDef amber fill:#18212f,stroke:#ffb454,color:#e9eef5; + classDef data fill:#101b22,stroke:#5fd5ee,color:#e9eef5; + classDef user fill:#16132a,stroke:#b69cff,color:#e9eef5; +
+
+
+ +
+

At a glance

+

What’s running on GCP

+
+
SERVICE

eureka-web

Cloud Run, region europe-west1, scales 0→5 instances.

+ +
IMAGE

Artifact Registry

europe-west1-docker.pkg.dev/eureka-362814/eureka/eureka-web

+
AUTH

Keyless (WIF)

GitHub OIDC → github-deployer SA. No key stored anywhere. How →

+
DATA

Supabase

Unchanged. Both clouds talk to the same Postgres over RLS-gated keys.

+
SENTRY

Monitoring

Source maps uploaded at build; errors tunneled via /monitoring.

+
+
+ +
+

Read next

+

Three ways in

+ +
+ +
+

Runbook

+

Operating it

+ +

Deploy a new revision (manual)

+
# full path: bootstrap (idempotent) + build + deploy
+deploy/deploy.sh
+
+# CI path (what GitHub Actions runs): skip bootstrap, just ship
+deploy/deploy.sh --deploy-only
+ +

Roll back to the previous revision

+
gcloud run revisions list --service eureka-web --region europe-west1
+gcloud run services update-traffic eureka-web \
+  --region europe-west1 --to-revisions PREVIOUS_REVISION=100
+ +

Tail logs

+
gcloud run services logs read eureka-web --region europe-west1 --limit 100
+ +

Rotate a secret

+
# add a new version, then redeploy to pick it up
+printf '%s' "$NEW_VALUE" | gcloud secrets versions add eureka-sentry-dsn --data-file=-
+deploy/deploy.sh --deploy-only
+ +
+ Migration status +

Parallel deployment is live and verified. Cutover is deliberately deferred: map the production domain to Cloud Run, flip DNS, watch traffic, then retire the Vercel project. Until then, GCP only deploys when the workflow runs — Vercel is untouched.

+
+
+ + + + + + diff --git a/docs/deployment/pipeline.html b/docs/deployment/pipeline.html new file mode 100644 index 0000000..a03ef4a --- /dev/null +++ b/docs/deployment/pipeline.html @@ -0,0 +1,151 @@ + + + + + + Eureka · Deployment — Pipeline + + + + + + +
+

CI/CD · GitHub Actions → Cloud Build → Cloud Run

+

From push
to revision.

+

A merge to master triggers .github/workflows/deploy-gcp.yml. It authenticates to Google Cloud with a short-lived token, hands off to deploy/deploy.sh --deploy-only, and the same script you can run by hand builds the image in Cloud Build and rolls it onto Cloud Run.

+
+ +
+

End to end

+

The deploy sequence

+

Nothing long-lived is stored in GitHub. The workflow mints an OIDC token, exchanges it for a federated identity, and impersonates the github-deployer service account — all in seconds, all expiring in minutes.

+ +
+
Sequence — a single deploy
+
+sequenceDiagram
+  autonumber
+  participant GH as GitHub Actions
+  participant OIDC as GitHub OIDC
+  participant STS as Google STS
+  participant SA as github-deployer SA
+  participant CB as Cloud Build
+  participant SM as Secret Manager
+  participant AR as Artifact Registry
+  participant CR as Cloud Run
+
+  GH->>OIDC: request id-token
+  OIDC-->>GH: signed JWT (repo, ref, sha)
+  GH->>STS: exchange JWT via WIF provider
+  STS-->>GH: federated token
+  GH->>SA: impersonate
+  SA-->>GH: short-lived access token
+  Note over GH,CR: deploy.sh --deploy-only
+  GH->>CB: builds submit (cloudbuild.yaml)
+  CB->>SM: fetch build-arg secrets
+  SM-->>CB: NEXT_PUBLIC_* + SENTRY_AUTH_TOKEN
+  CB->>CB: docker build · next build
+  CB->>AR: push :latest + :BUILD_ID
+  GH->>CR: run deploy --image :latest
+  CR->>SM: bind SENTRY_DSN at runtime
+  CR-->>GH: revision serving 100%
+      
+
+ +
    +
  1. Push to master The workflow triggers (it ignores doc-only changes under docs/** and **.md).
  2. +
  3. Keyless auth google-github-actions/auth exchanges the GitHub OIDC token for credentials that impersonate the deployer SA. See Security.
  4. +
  5. Build in Cloud Build cloudbuild.yaml pulls build-time secrets from Secret Manager and runs the multi-stage Docker build.
  6. +
  7. Push to Artifact Registry Tagged :latest and :$BUILD_ID for rollback.
  8. +
  9. Deploy to Cloud Run New revision, runtime SENTRY_DSN from Secret Manager, traffic shifts to 100%.
  10. +
+
+ +
+

The image

+

A lean, multi-stage container

+

Next.js builds in standalone mode, so the runtime image carries only a minimal server bundle — not the full node_modules. The build runs on Debian-based node:24-slim (not Alpine) because the app pulls in sharp for image optimisation, which wants glibc.

+ +
+
Dockerfile — three stages, one discarded
+
+flowchart TD
+  src["Source · yarn.lock · .yarn/releases"]:::src
+  subgraph deps["stage: deps"]
+    d1["yarn install --immutable"]
+  end
+  subgraph builder["stage: builder"]
+    b1["copy node_modules + source"]
+    b2["NEXT_PUBLIC_* + SENTRY_AUTH_TOKEN
injected as build args"] + b3["next build → .next/standalone"] + end + subgraph runner["stage: runner · node:24-slim"] + r1["copy standalone + static + public"] + r2["run as non-root 'node'"] + r3["node server.js · :8080"] + end + src --> deps --> builder --> runner + builder -. "builder stage discarded
SENTRY_AUTH_TOKEN never ships" .-> drop{{"🗑 not in final image"}}:::warn + + classDef src fill:#0e131c,stroke:#5fd5ee,color:#e9eef5; + classDef warn fill:#1c1710,stroke:#ffb454,color:#ffd9a0; +
+
+ +
+ Build-time vs runtime +

NEXT_PUBLIC_* values (Supabase URL, publishable key, Sentry DSN) are inlined into the client bundle at build time, so they must be present during next build — hence build args, not runtime env. SENTRY_DSN is the only value injected at runtime. SENTRY_AUTH_TOKEN exists only to upload source maps and is confined to the discarded builder stage.

+
+
+ +
+

At runtime

+

Where the data goes

+

Cloud Run is stateless. Every read and write goes to Supabase over an RLS-gated publishable key — the exact same database Vercel uses, which is what makes the two deployments interchangeable.

+ +
+
Runtime data flow
+
+flowchart LR
+  user((Player)):::user --> cr["Cloud Run
eureka-web · :8080"]:::run + cr -->|"publishable key · RLS"| supa[("Supabase
Postgres")]:::data + cr -->|"events via /monitoring tunnel"| sentry[("Sentry")]:::amber + sm[("Secret Manager")]:::violet -. "SENTRY_DSN at start" .-> cr + + classDef run fill:#18212f,stroke:#c9f24e,color:#e9eef5,stroke-width:2px; + classDef data fill:#101b22,stroke:#5fd5ee,color:#e9eef5; + classDef amber fill:#18212f,stroke:#ffb454,color:#e9eef5; + classDef violet fill:#16132a,stroke:#b69cff,color:#e9eef5; + classDef user fill:#16132a,stroke:#b69cff,color:#e9eef5; +
+
+ +
+ Scale-to-zero note +

With min-instances 0, the /highscores ISR cache doesn’t persist across cold starts — each cold start re-renders (~0.13s). Negligible at this traffic; set --min-instances 1 for Vercel-identical cache warmth.

+
+
+ + + + + + diff --git a/docs/deployment/security.html b/docs/deployment/security.html new file mode 100644 index 0000000..9b4964f --- /dev/null +++ b/docs/deployment/security.html @@ -0,0 +1,163 @@ + + + + + + Eureka · Deployment — Security + + + + + + +
+

Workload Identity Federation · zero stored keys

+

No keys.
Anywhere.

+

The classic way to let CI deploy to GCP is to mint a service-account JSON key and paste it into a GitHub secret. That key is a long-lived bearer credential — if it leaks, anyone is you, until you notice and rotate. Eureka stores no key at all. GitHub proves its identity per run with a signed token; Google trades it for credentials that expire in minutes.

+
+ +
+

The trust chain

+

How a workflow becomes a deployer

+

GitHub Actions is an OpenID Connect (OIDC) identity provider. Each run can request a short-lived JWT that asserts which repository, branch, and commit it belongs to. Google’s Workload Identity Federation trusts that issuer, verifies the claims against a condition we set, and only then lets the token impersonate a service account.

+ +
+
Sequence — token exchange
+
+sequenceDiagram
+  autonumber
+  participant W as Workflow run
+  participant O as GitHub OIDC issuer
+  participant S as Google STS
+  participant P as WIF provider
+  participant A as github-deployer SA
+  participant G as GCP APIs
+
+  W->>O: give me an id-token
+  O-->>W: JWT { repository: riethmayer/eureka, ref, sha, ... }
+  W->>S: exchange this JWT
+  S->>P: verify issuer + signature
+  P->>P: attribute-condition:
repository == 'riethmayer/eureka'? + alt condition matches + P-->>S: ok + S-->>W: federated token + W->>A: impersonate (workloadIdentityUser) + A-->>W: access token · TTL ~minutes + W->>G: build & deploy + else any other repo + P-->>S: reject + S-->>W: 403 — no credentials + end +
+
+
+ +
+

The lock

+

Two independent gates, both pinned to one repo

+

The repository identity is enforced in two places, so a misconfiguration in one doesn’t open the door:

+ +

1 · Provider attribute condition

+

The OIDC provider refuses to issue a federated token unless the JWT’s repository claim matches exactly:

+
--attribute-mapping=\
+  "google.subject=assertion.sub,\
+   attribute.repository=assertion.repository,\
+   attribute.repository_owner=assertion.repository_owner"
+--attribute-condition="assertion.repository=='riethmayer/eureka'"
+ +

2 · Service-account impersonation binding

+

Even a valid federated token can only impersonate the SA if it carries this repo’s attribute — the principalSet is scoped to the repository, not the whole pool:

+
principalSet://iam.googleapis.com/projects/369713805962/
+  locations/global/workloadIdentityPools/github-pool/
+  attribute.repository/riethmayer/eureka
+    → roles/iam.workloadIdentityUser on github-deployer
+ +
+ Why two gates +

The provider condition stops foreign repos at the token-exchange step. The principalSet binding stops them again at impersonation. A fork opening a PR can’t abuse it either — GitHub restricts OIDC tokens on fork PRs, and the repository claim wouldn’t match regardless.

+
+
+ +
+

Least privilege

+

What the deployer can — and can’t — do

+

The github-deployer service account holds only the roles required to build and ship. It cannot read application data, cannot touch other projects, and was deliberately not granted secret-access (the build’s own service account reads secrets, not the deployer).

+ +
+ + + + + + + + + + +
RoleGrantsWhy it’s needed
cloudbuild.builds.editorSubmit / run buildsTrigger the image build
storage.objectAdmin + bucket storage.adminWrite to the staging bucketUpload build source
serviceusage.serviceUsageConsumerUse project APIsRequired for builds submit
artifactregistry.writerRead/write imagesReference the pushed image on deploy
run.adminManage Cloud RunCreate revisions, set public access
iam.serviceAccountUser on runtime SAactAs the compute SADeploy a service that runs as that identity
+
+ +
+ Blast radius if the token leaked +

A leaked federated token expires in minutes and only works from a GitHub run that already matched the repo condition. A leaked SA access token could deploy images to this project — but cannot read Supabase data, cannot reach other GCP projects, and there is no static key to find in the first place. Compare that to a JSON key sitting in a CI secret indefinitely.

+
+
+ +
+

Secrets

+

Single source of truth, never in the image

+

Secret Manager replaces what used to live in Vercel’s env panel. Values are classified by when they’re needed and handled accordingly.

+ +
+
+
BUILD-TIME
+

Inlined or discarded

+

NEXT_PUBLIC_* (Supabase URL, publishable key, Sentry DSN) are public-by-design and get baked into the client bundle. SENTRY_AUTH_TOKEN is sensitive but used only to upload source maps — it lives in the builder stage, which is thrown away.

+
+
+
RUNTIME
+

Injected, not baked

+

SENTRY_DSN is mounted into the Cloud Run container from Secret Manager at start-up via --set-secrets — never written into the image layers.

+
+
+ +
+
Secret lifecycle
+
+flowchart LR
+  sm[("Secret Manager
eureka-* secrets")]:::violet + sm -->|"availableSecrets → build args"| cb["Cloud Build
builder stage"]:::amber + cb -->|"inlined into client JS"| img["image (runner stage)"]:::run + cb -. "SENTRY_AUTH_TOKEN
stays in discarded stage" .-> trash{{"🗑 not shipped"}}:::warn + sm -->|"--set-secrets at deploy"| cr["Cloud Run runtime"]:::run + + classDef violet fill:#16132a,stroke:#b69cff,color:#e9eef5; + classDef amber fill:#18212f,stroke:#ffb454,color:#e9eef5; + classDef run fill:#18212f,stroke:#c9f24e,color:#e9eef5,stroke-width:2px; + classDef warn fill:#1c1710,stroke:#ffb454,color:#ffd9a0; +
+
+
+ + + + + + diff --git a/docs/deployment/tradeoffs.html b/docs/deployment/tradeoffs.html new file mode 100644 index 0000000..3d0b409 --- /dev/null +++ b/docs/deployment/tradeoffs.html @@ -0,0 +1,109 @@ + + + + + + Eureka · Deployment — Tradeoffs + + + + + + +
+

Decisions & alternatives

+

Why this,
not that.

+

Three choices define this setup: where the app runs, how CI proves who it is, and how the image gets built. Each had real alternatives. Here’s the reasoning — and when you’d pick differently.

+
+ +
+

Decision 1 — compute

+

Where it runs: Cloud Run

+

Eureka is a single containerised Next.js app with bursty, low-baseline traffic. That profile points straight at a serverless container platform: scale to zero when idle, scale out under load, no servers to patch.

+ +
+ + + + + + + + + + +
OptionModelOps burdenScale to 0Verdict for Eureka
Cloud RunServerless containerslowyesChosen — closest match to Vercel’s model, full container control
VercelManaged Next.jsnoneyesThe incumbent — superb DX, but vendor lock-in & the thing we’re migrating off
GKEKuberneteshighvia KEDAOverkill — a cluster to run one web app
App EnginePaaSlowstd onlyOlder model, less container flexibility than Run
Compute EngineVMshighnoYou own the OS, patching, autoscaling — too much
Firebase Hosting + FunctionsStatic + FaaSlowyesGreat for static/JAMstack; awkward fit for a full SSR container
+
+
+ When you'd pick differently +

Reach for GKE once you’re running many services with shared networking, sidecars, or strict pod-level control. Stay on Vercel if developer velocity and preview deployments matter more than cost and control. For one app that must just run cheaply and reliably, Cloud Run wins.

+
+
+ +
+

Decision 2 — CI identity

+

How CI authenticates: Workload Identity Federation

+

CI needs to act on GCP. The question is whether it holds a standing credential or proves itself per run. We chose per-run.

+ +
+ + + + + + + +
OptionStored secret?Credential lifeLeak impactVerdict
Workload Identity FederationnoneminutescontainedChosen — keyless, repo-scoped, auto-expiring
SA JSON key in GitHub secretyesuntil rotatedhighSimplest to set up; a permanent bearer credential is a liability
SA key in a vault / Secret Manageryesuntil rotatedmediumAdds a fetch hop but the key is still long-lived
+
+

The cost of WIF is one-time setup complexity — a pool, a provider, a condition, and an impersonation binding. The payoff is permanent: there is no key to rotate, leak, or audit. See Security for the full mechanism.

+
+ +
+

Decision 3 — build

+

How the image is built: Cloud Build

+

The build needs build-time secrets (the NEXT_PUBLIC_* values and the Sentry token). Doing it inside Google Cloud lets those secrets stay in Secret Manager and never transit the CI runner.

+ +
+ + + + + + + +
OptionWhere it buildsSecret handlingControlVerdict
Cloud BuildIn GCPnative (Secret Manager)full DockerfileChosen — secrets never leave GCP; no Docker in the runner
Docker build in Actions, push to ARIn the runnerfetch into runner envfull DockerfileFast layer caching, but secrets pass through GitHub’s runner
Cloud Run --source (buildpacks)In GCPawkward build argsno multi-stageSimplest, but can’t cleanly inline NEXT_PUBLIC_* or discard the Sentry token
+
+
+ The deciding factor +

NEXT_PUBLIC_* must be present during next build (they’re inlined into client JS), and SENTRY_AUTH_TOKEN must be available to the build but absent from the final image. A hand-written multi-stage Dockerfile + Cloud Build’s availableSecrets expresses both cleanly; buildpacks don’t.

+
+
+ +
+

In one sentence

+

The throughline

+

Run the container on the platform that mirrors Vercel’s ergonomics (Cloud Run), let CI prove itself per run instead of holding a key (WIF), and build where the secrets already live (Cloud Build) — so the whole path is portable, auditable, and keyless from push to production.

+
+ + + + + + diff --git a/next.config.mjs b/next.config.mjs index 77090cf..ddfbe38 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,18 @@ import { withSentryConfig } from "@sentry/nextjs"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); /** @type {import('next').NextConfig} */ const nextConfig = { + // Emit a self-contained server bundle (.next/standalone) so the Docker + // image for Cloud Run stays minimal and doesn't need the full node_modules. + output: "standalone", + // Pin the file-tracing root to this app dir. Without it, Next walks up to the + // parent repo's lockfile (this is a git worktree under .claude/) and nests + // standalone/server.js under a subpath, breaking the Dockerfile COPY. + outputFileTracingRoot: __dirname, reactStrictMode: true, allowedDevOrigins: ["192.168.178.61"], images: {