Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions .github/workflows/deploy-gcp.yml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
57 changes: 57 additions & 0 deletions cloudbuild.yaml
Original file line number Diff line number Diff line change
@@ -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
85 changes: 85 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 118 additions & 0 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading