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
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Deploy API

# Runs after "PR checks" passes on a push to main, so nothing deploys that CI
# has not checked as merged. workflow_run cannot filter by path, so every green
# merge redeploys: the image is rebuilt from scratch (each runner starts empty)
# and the migrations are a no-op when there is nothing new.
# CI runs on main start in merge order (see pr.yml's concurrency), but a pending
# run is replaced when a newer one queues. So an intermediate merge can end up
# with no CI run and no deploy of its own; the next merge's deploy includes it.
# The GCP resources and the three repository variables come from
# infra/setup-gcp.sh.
on:
workflow_run:
workflows: [PR checks]
types: [completed]
branches: [main]
workflow_dispatch:

# One deploy at a time, and never cancel one halfway through a migration. A
# pending deploy is still replaced by a newer one, which is the one to keep.
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
# The commit CI checked, not whatever main points to by now.
SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
RUNTIME_SA: api-runtime@${{ vars.GCP_PROJECT_ID }}.iam.gserviceaccount.com
CLOUDSDK_CORE_PROJECT: ${{ vars.GCP_PROJECT_ID }}

jobs:
deploy:
# branches: [main] above matches a fork PR whose branch is also called main,
# so also require a green push to this repository.
if: >-
github.event_name == 'workflow_dispatch' || (
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository
)
runs-on: ubuntu-latest
env:
IMAGE: europe-west1-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/api/api:${{ github.event.workflow_run.head_sha || github.sha }}
# Actions pinned by commit SHA: this is the only workflow that can get GCP
# credentials (id-token: write), and a tag can be moved, a SHA cannot. To
# update one: gh api repos/<owner>/<action>/commits/<tag> --jq .sha
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.SHA }}
persist-credentials: false

- uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_DEPLOYER_SA }}

- uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1

- 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
# passes through 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

# --no-traffic: the new revision only gets the "candidate" URL until its
# /health passes below. ^;^ switches gcloud's list separator so the comma
# stays inside CORS_ORIGIN.
- name: Deploy the new revision without traffic
run: |
gcloud run deploy api \
--region="$REGION" \
--image="$IMAGE" \
--no-traffic --tag=candidate \
--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.
# If this fails, the workflow stops and the previous revision keeps all
# the traffic.
- name: Check /health on the new revision
run: |
url="$(gcloud run services describe api --region="$REGION" --format=json \
| jq -r '.status.traffic[] | select(.tag == "candidate") | .url')"
curl -fsS --retry 5 --retry-all-errors "$url/health"

- name: Send all traffic to the new revision
run: gcloud run services update-traffic api --region="$REGION" --to-latest
17 changes: 16 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ on:
branches: [main]

# A new push to a PR cancels the run still going for its previous commit.
# Runs on main are never cancelled, so every merge gets a full result.
# A run already going on main is never cancelled, but GitHub still replaces a
# pending run on main when a newer one queues, so a merge in between can end up
# with no run of its own; the newer run checks its changes too.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Expand Down Expand Up @@ -91,6 +93,19 @@ jobs:
exit 1
fi

# Builds the production image of the API, without pushing it. The Dockerfile
# only copies packages/config and apps/api, so if the API starts depending on
# a new package in packages/, this job goes red here instead of the deploy
# breaking on main.
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- run: docker build -f apps/api/Dockerfile .

gitleaks:
runs-on: ubuntu-latest
permissions:
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ Run these from the repo root.

To run something in one package only: `pnpm --filter @acmfeup/api <script>`.

## Deploy

The web app deploys on Vercel. The API deploys itself: once CI passes on a merge to `main`, [`deploy-api.yml`](.github/workflows/deploy-api.yml)

1. builds `apps/api/Dockerfile` and pushes it to Artifact Registry,
2. runs the migrations as the `api-migrate` Cloud Run Job and waits for it,
3. deploys the new image to the `api` Cloud Run service (europe-west1, scales to zero, at most 2 instances) without traffic, checks its `/health`, and only then moves traffic to it.

If the migrations or the health check fail, the workflow stops and the previous version stays live. The database is on Neon (Frankfurt). Its connection string is stored only in Secret Manager (`database-url`): not in the repo, not in GitHub, and it never passes through the GitHub runner. GitHub gets short-lived credentials through Workload Identity Federation, and only for `deploy-api.yml` running on `main`. The code that workflow deploys runs with the connection string, so merging to `main` is as sensitive as holding the string itself.

Vercel preview deployments cannot call the production API: `CORS_ORIGIN` only allows `acmfeup.eu` and `www.acmfeup.eu`. This is on purpose. A preview runs new frontend code, which should not run against the production API and its data, and a `*.vercel.app` pattern would also match other people's projects.

**Migrations run before the new code is live, so they must be backwards compatible with the API version currently running.** For the whole window between them, and after any rollback, the old code runs against the new schema. The rules are in [CONTRIBUTING.md](CONTRIBUTING.md#database-migrations).

The GCP side is created by [`infra/setup-gcp.sh`](infra/setup-gcp.sh). Run it once in Cloud Shell (`bash infra/setup-gcp.sh <PROJECT_ID>`), and set the three repository variables it prints, before the first merge that should deploy; until then the deploy workflow fails at authentication. Running it again is safe, and it is also how you rotate the database password: paste the new connection string when it asks, then run the Deploy API workflow by hand, so that the running instances pick up the new string.

## Contributing

Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening your first issue or PR. If you work with a coding agent, it should read [AGENTS.md](AGENTS.md).
33 changes: 33 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
14 changes: 14 additions & 0 deletions apps/api/src/db/migrate.ts
Original file line number Diff line number Diff line change
@@ -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());
3 changes: 3 additions & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { API_PORT } from "./config/env";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: parseCorsOrigins(process.env.CORS_ORIGIN) });
// Handle SIGTERM: in a container node is PID 1, which ignores it unless a
// handler exists, so Cloud Run would SIGKILL mid-request after the grace period.
app.enableShutdownHooks();

await app.listen(API_PORT);
Logger.log(`api listening on http://localhost:${API_PORT}`, "Bootstrap");
Expand Down
Loading
Loading