From 5d044b15ee7da71c1bffafd7d79f4a0601793b53 Mon Sep 17 00:00:00 2001 From: bebaz Date: Tue, 22 Sep 2026 20:53:21 +0100 Subject: [PATCH 01/14] feat(api): add runtime migrator for production drizzle-kit is a dev dependency and stays out of the image, so production migrations use drizzle-orm's migrator. It is the same code drizzle-kit migrate runs and records history in the same drizzle.__drizzle_migrations table, so local and production never apply a migration twice. --- apps/api/src/db/migrate.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/api/src/db/migrate.ts diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..6aa07a8 --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -0,0 +1,14 @@ +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { db, pool } from "./client"; + +// Production migrations, run by the api-migrate Cloud Run Job. drizzle-kit is a +// dev dependency and stays out of the image, so this uses drizzle-orm's own +// migrator, which drizzle-kit migrate calls under the hood: both record applied +// migrations in drizzle.__drizzle_migrations. Keep the defaults, or the two stop +// seeing each other's history. cwd is apps/api locally and /app in the image. +migrate(db, { migrationsFolder: "drizzle" }) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => pool.end()); From dc71af1b61ff9b5e116b648898cec403df082bfc Mon Sep 17 00:00:00 2001 From: bebaz Date: Tue, 22 Sep 2026 20:53:21 +0100 Subject: [PATCH 02/14] build(api): add production Dockerfile Two stages: build with the full workspace, then ship only dist, the drizzle migrations and production node_modules from pnpm deploy --prod. The .dockerignore keeps .env files and the web app out of the context. --- .dockerignore | 12 ++++++++++++ apps/api/Dockerfile | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .dockerignore create mode 100644 apps/api/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8958b89 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Keep secrets, local builds and the web app out of the API image's build context. +.env* +**/node_modules +**/dist +**/.next +**/*.tsbuildinfo +.git +.turbo +.idea +.direnv +apps/web/* +!apps/web/package.json diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..de5812c --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,33 @@ +# Production image of the API. Build from the repo root, which has the lockfile +# and the workspace: +# docker build -f apps/api/Dockerfile -t acmfeup-api . + +FROM node:24.21.0-slim AS build +WORKDIR /repo +# corepack installs the pnpm pinned in packageManager; LEFTHOOK=0 skips git hooks. +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 LEFTHOOK=0 +RUN corepack enable + +# Manifests first, so the install layer is cached until dependencies change. +# apps/web/package.json is only there because the lockfile lists it. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/config/package.json packages/config/ +COPY apps/api/package.json apps/api/ +COPY apps/web/package.json apps/web/ +RUN pnpm install --frozen-lockfile --filter @acmfeup/api... + +COPY packages/config packages/config +COPY apps/api apps/api +RUN pnpm --filter @acmfeup/api build \ + && pnpm --filter @acmfeup/api deploy --prod --legacy /out + +FROM node:24.21.0-slim +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /out/package.json ./ +COPY --from=build /out/node_modules node_modules +COPY --from=build /repo/apps/api/dist dist +COPY --from=build /repo/apps/api/drizzle drizzle +USER node +EXPOSE 4000 +CMD ["node", "dist/main.js"] From 16ddc78b76e8eee7aa5fc1903a30f9140ed57995 Mon Sep 17 00:00:00 2001 From: bebaz Date: Tue, 22 Sep 2026 20:54:24 +0100 Subject: [PATCH 03/14] chore(infra): add idempotent GCP setup script One script, run once in Cloud Shell, creates everything the deploy workflow needs: Artifact Registry, the database-url secret (read without echo), a runtime service account that can only read that secret, a deployer account for GitHub via Workload Identity Federation limited to main of this repo, and the public Cloud Run service. Re-running it keeps what exists. --- infra/setup-gcp.sh | 174 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100755 infra/setup-gcp.sh diff --git a/infra/setup-gcp.sh b/infra/setup-gcp.sh new file mode 100755 index 0000000..f1b45a3 --- /dev/null +++ b/infra/setup-gcp.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# One-time GCP setup for deploying the API to Cloud Run. Run it in Cloud Shell: +# +# bash infra/setup-gcp.sh +# +# Safe to run again: existing resources are left as they are, and IAM bindings +# are additive. It asks for the Neon connection string without echoing it; press +# Enter to keep the one already stored. The .github/workflows/deploy-api.yml +# workflow relies on every name below. +set -euo pipefail + +PROJECT_ID="${1:?usage: bash infra/setup-gcp.sh }" +export CLOUDSDK_CORE_PROJECT="$PROJECT_ID" + +REGION=europe-west1 +REPO=api # Artifact Registry repository +SERVICE=api # Cloud Run service +SECRET=database-url # Secret Manager secret with the Neon connection string +GITHUB_REPO=acmfeup/new-website +POOL=github +PROVIDER=github-actions +RUNTIME_SA="api-runtime@$PROJECT_ID.iam.gserviceaccount.com" +DEPLOYER_SA="github-deployer@$PROJECT_ID.iam.gserviceaccount.com" + +step() { printf '\n==> %s\n' "$*"; } +# The describe calls are existence checks; their "not found" output is noise. +exists() { "$@" >/dev/null 2>&1; } + +PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')" + +step "Enabling APIs" +gcloud services enable run.googleapis.com artifactregistry.googleapis.com \ + secretmanager.googleapis.com iam.googleapis.com iamcredentials.googleapis.com \ + sts.googleapis.com cloudresourcemanager.googleapis.com + +step "Artifact Registry repository '$REPO'" +if ! exists gcloud artifacts repositories describe "$REPO" --location="$REGION"; then + gcloud artifacts repositories create "$REPO" --location="$REGION" \ + --repository-format=docker --description="API images, pushed by GitHub Actions" +fi +# Keep the 10 newest images (enough to roll back) and delete the rest. +policy="$(mktemp)" +cat >"$policy" <<'JSON' +[ + {"name": "keep-recent", "action": {"type": "Keep"}, "mostRecentVersions": {"keepCount": 10}}, + {"name": "delete-old", "action": {"type": "Delete"}, "condition": {"tagState": "any"}} +] +JSON +gcloud artifacts repositories set-cleanup-policies "$REPO" --location="$REGION" \ + --policy="$policy" --no-dry-run >/dev/null +rm -f "$policy" + +step "Secret '$SECRET'" +if ! exists gcloud secrets describe "$SECRET"; then + gcloud secrets create "$SECRET" --replication-policy=user-managed --locations="$REGION" +fi +has_version="$(gcloud secrets versions list "$SECRET" --filter='state:ENABLED' --limit=1 --format='value(name)')" +while true; do + prompt="Neon direct connection string" + [ -n "$has_version" ] && prompt="$prompt (Enter keeps the current one)" + read -rsp "$prompt: " url + echo + if [ -z "$url" ] && [ -n "$has_version" ]; then + echo "Keeping the current version." + break + fi + case "$url" in + postgres://* | postgresql://*) ;; + *) echo "That does not look like a postgres:// URL. Try again."; continue ;; + esac + case "$url" in + *-pooler.*) echo "That is the pooled host (-pooler). Use the direct connection string."; continue ;; + esac + printf '%s' "$url" | gcloud secrets versions add "$SECRET" --data-file=- >/dev/null + echo "Stored a new version." + break +done +unset url + +step "Service accounts" +if ! exists gcloud iam service-accounts describe "$RUNTIME_SA"; then + gcloud iam service-accounts create api-runtime --display-name="API (Cloud Run runtime)" +fi +if ! exists gcloud iam service-accounts describe "$DEPLOYER_SA"; then + gcloud iam service-accounts create github-deployer --display-name="GitHub Actions deployer" +fi + +step "Permissions" +# Runtime: read this one secret, nothing else. +gcloud secrets add-iam-policy-binding "$SECRET" --member="serviceAccount:$RUNTIME_SA" \ + --role=roles/secretmanager.secretAccessor >/dev/null +# Deployer: push images to this repository, deploy the service and the +# migration job, and run them as the runtime account. It cannot read the secret. +gcloud artifacts repositories add-iam-policy-binding "$REPO" --location="$REGION" \ + --member="serviceAccount:$DEPLOYER_SA" --role=roles/artifactregistry.writer >/dev/null +gcloud projects add-iam-policy-binding "$PROJECT_ID" --member="serviceAccount:$DEPLOYER_SA" \ + --role=roles/run.developer --condition=None >/dev/null +gcloud iam service-accounts add-iam-policy-binding "$RUNTIME_SA" \ + --member="serviceAccount:$DEPLOYER_SA" --role=roles/iam.serviceAccountUser >/dev/null + +step "Workload Identity Federation for $GITHUB_REPO" +# The numeric id, not the name: a deleted repo's name can be taken by someone else. +repo_id="$(curl -fsS "https://api.github.com/repos/$GITHUB_REPO" | jq -r .id)" || repo_id="" +if [ -z "$repo_id" ]; then + read -rp "Could not look up the GitHub repo id. Enter it (gh api repos/$GITHUB_REPO --jq .id): " repo_id +fi +# Only workflows running on main of this repo get credentials: PRs, forks and +# other branches are rejected by the provider before any role is checked. +condition="assertion.repository_id=='$repo_id' && assertion.ref=='refs/heads/main'" +mapping="google.subject=assertion.sub,attribute.repository_id=assertion.repository_id,attribute.ref=assertion.ref" +if ! exists gcloud iam workload-identity-pools describe "$POOL" --location=global; then + gcloud iam workload-identity-pools create "$POOL" --location=global --display-name="GitHub Actions" +fi +if exists gcloud iam workload-identity-pools providers describe "$PROVIDER" \ + --workload-identity-pool="$POOL" --location=global; then + gcloud iam workload-identity-pools providers update-oidc "$PROVIDER" \ + --workload-identity-pool="$POOL" --location=global \ + --attribute-mapping="$mapping" --attribute-condition="$condition" >/dev/null +else + gcloud iam workload-identity-pools providers create-oidc "$PROVIDER" \ + --workload-identity-pool="$POOL" --location=global --display-name="GitHub Actions" \ + --issuer-uri=https://token.actions.githubusercontent.com \ + --attribute-mapping="$mapping" --attribute-condition="$condition" +fi +gcloud iam service-accounts add-iam-policy-binding "$DEPLOYER_SA" \ + --role=roles/iam.workloadIdentityUser \ + --member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL/attribute.repository_id/$repo_id" \ + >/dev/null + +step "Cloud Run service '$SERVICE'" +# Created here with a placeholder image so that making it public stays a human +# decision: the deployer can deploy new revisions but cannot change IAM. +if ! exists gcloud run services describe "$SERVICE" --region="$REGION"; then + gcloud run deploy "$SERVICE" --region="$REGION" \ + --image=us-docker.pkg.dev/cloudrun/container/hello \ + --service-account="$RUNTIME_SA" --no-allow-unauthenticated \ + --min-instances=0 --max-instances=1 +fi +if ! err="$(gcloud run services add-iam-policy-binding "$SERVICE" --region="$REGION" \ + --member=allUsers --role=roles/run.invoker 2>&1 >/dev/null)"; then + if grep -qiE 'allowedPolicyMemberDomains|permitted customer|FAILED_PRECONDITION' <<<"$err"; then + cat >&2 < Organization Policies > "Domain restricted sharing" > + Manage policy > Override parent's policy > Allow all), or + - Create the project outside the organisation (no organisation). +EOF + else + echo "$err" >&2 + fi + exit 1 +fi + +url="$(gcloud run services describe "$SERVICE" --region="$REGION" --format='value(status.url)')" + +cat < Secrets and variables > +Actions > Variables), not secrets: + + GCP_PROJECT_ID = $PROJECT_ID + GCP_WIF_PROVIDER = projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL/providers/$PROVIDER + GCP_DEPLOYER_SA = $DEPLOYER_SA + +API URL, for NEXT_PUBLIC_API_URL on Vercel: + + $url +EOF From 275de688f1dac87507c30c3640bfdc9c1ae3664e Mon Sep 17 00:00:00 2001 From: bebaz Date: Tue, 22 Sep 2026 20:54:48 +0100 Subject: [PATCH 04/14] ci: deploy API to Cloud Run on merge to main Builds the image, pushes it to Artifact Registry, runs the migrations as a Cloud Run Job and waits for it, then deploys the service. A failed migration stops the workflow and the previous revision keeps serving. Authenticates with Workload Identity Federation, so there is no key and the database URL never reaches the runner. --- .github/workflows/deploy-api.yml | 89 ++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/deploy-api.yml diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 0000000..0df76ca --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,89 @@ +name: Deploy API + +# Every merge to main that can change the API image. The GCP resources and the +# three repository variables come from infra/setup-gcp.sh. +on: + push: + branches: [main] + paths: + - apps/api/** + - packages/config/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .dockerignore + - .github/workflows/deploy-api.yml + workflow_dispatch: + +# One deploy at a time, and never cancel one halfway through a migration. +concurrency: + group: deploy-api + cancel-in-progress: false + +permissions: + contents: read + # Lets the job request the OIDC token that Workload Identity Federation trades + # for short-lived GCP credentials. There is no service account key anywhere. + id-token: write + +env: + REGION: europe-west1 + IMAGE: europe-west1-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/api/api:${{ github.sha }} + RUNTIME_SA: api-runtime@${{ vars.GCP_PROJECT_ID }}.iam.gserviceaccount.com + CLOUDSDK_CORE_PROJECT: ${{ vars.GCP_PROJECT_ID }} + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: ${{ vars.GCP_DEPLOYER_SA }} + + - uses: google-github-actions/setup-gcloud@v3 + + - name: Build and push the image + run: | + gcloud auth configure-docker europe-west1-docker.pkg.dev --quiet + docker build -f apps/api/Dockerfile -t "$IMAGE" . + docker push "$IMAGE" + + # Runs inside GCP as the runtime account, so the connection string never + # reaches this runner. --wait makes the step fail if the migration fails, + # which stops the workflow before the new revision is deployed: the + # previous one keeps serving. + - name: Run migrations + run: | + gcloud run jobs deploy api-migrate \ + --region="$REGION" \ + --image="$IMAGE" \ + --command=node --args=dist/db/migrate.js \ + --service-account="$RUNTIME_SA" \ + --set-secrets=DATABASE_URL=database-url:latest \ + --max-retries=0 --task-timeout=5m \ + --execute-now --wait + + # ^;^ switches gcloud's list separator so the comma stays inside CORS_ORIGIN. + - name: Deploy the service + run: | + gcloud run deploy api \ + --region="$REGION" \ + --image="$IMAGE" \ + --port=4000 \ + --service-account="$RUNTIME_SA" \ + --set-secrets=DATABASE_URL=database-url:latest \ + --set-env-vars="^;^CORS_ORIGIN=https://acmfeup.eu,https://www.acmfeup.eu" \ + --min-instances=0 --max-instances=2 \ + --cpu=1 --memory=512Mi \ + --quiet + + # Retries cover the cold start of the new instance and of Neon's compute. + - name: Check /health + run: | + url="$(gcloud run services describe api --region="$REGION" --format='value(status.url)')" + curl -fsS --retry 5 --retry-all-errors "$url/health" From a6c8d8e1f07e04fac021251f30ee04d98e5ceb7c Mon Sep 17 00:00:00 2001 From: bebaz Date: Tue, 22 Sep 2026 20:55:13 +0100 Subject: [PATCH 05/14] docs: document API deploy --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 5306474..242cf59 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,20 @@ Run these from the repo root. To run something in one package only: `pnpm --filter @acmfeup/api