The backend repository contains two deployment mechanisms. Both are GitHub Actions workflows. Server addresses, users and credentials come from repository secrets and are not documented here.
A push to dev starts the Docker workflow immediately. It does not wait for the CI workflow, and the backend CI workflow does not run on pushes to dev. Run pnpm ci locally, or merge through a pull request, before anything reaches dev.

What gets built

Both mechanisms produce the same program: the compiled API at apps/core-api/dist/server.js, started with plain node. Workers and schedulers run inside that one process. See System architecture. pnpm build runs turbo run build. Each package builds after the packages it depends on. @repo/database runs prisma generate, then tsup, then tsc. @perform/db runs prisma generate and tsc. The API runs tsc -p tsconfig.build.json.

Docker image workflow

The Dockerfile

backend/Dockerfile has two stages, both on node:22-bookworm-slim with openssl and ca-certificates installed and Corepack enabled.
1

Builder stage

Copies the whole repository, runs pnpm install --frozen-lockfile, runs prisma generate for @perform/db, then pnpm build. A placeholder DATABASE_URL is set so Prisma config loading does not fail. No database is contacted during the build.
2

Runtime stage

Copies the entire /app tree from the builder, including node_modules and sources, sets NODE_ENV=production, exposes port 3031 and runs node apps/core-api/dist/server.js.
.dockerignore keeps node_modules, dist, .turbo, .git, .github, every .env file, logs and coverage out of the build context. No env file is baked into the image. Runtime configuration is injected by the host.

The workflow

  1. Check out the repository and set up Docker Buildx.
  2. Log in to the GitHub Container Registry with the workflow token.
  3. Build the image and push it with two tags: latest and the commit SHA. Layers are cached in the GitHub Actions cache.
  4. Connect to the host over SSH with a key from secrets and run, in the application directory:
    • docker login to the registry,
    • docker compose pull,
    • a one-off container that runs prisma db push against packages/db/prisma/schema.prisma,
    • docker compose up -d,
    • docker image prune -f.
A new push to the same branch cancels a run that is still in progress (concurrency with cancel-in-progress). The Compose file lives on the host and is not in the repository. It defines the api service the workflow refers to and supplies the environment variables.
The schema step is prisma db push, not prisma migrate deploy. If it fails, for example because the change would lose data, the workflow prints a warning and continues to restart the API on the new image. Schema drift is then yours to resolve by hand. Read Database migrations before you merge a schema change.

Rolling back

Every image is also tagged with its commit SHA. To roll back, point the Compose service at the SHA tag of the last good commit and run docker compose up -d on the host, or revert the commit on dev and let the workflow build again. A rollback does not undo schema changes.

pm2 release workflow

dev.yml is started by hand from the Actions tab. It builds on the runner and ships a self-contained bundle.

Steps on the runner

  1. Node 24 and pnpm, pnpm install --frozen-lockfile.
  2. pnpm db:generate, pnpm typecheck, pnpm build.
  3. Open an SSH tunnel to the host’s Postgres, read the runtime DATABASE_URL from the host’s shared env file, rewrite its host to the tunnel, mask it in the logs, and run prisma db push for @repo/database.
  4. Compute a release id from the UTC time, in the form YYYYMMDDTHHMMSSZ.
  5. Build the bundle: pnpm --filter=@perform/core-api deploy --legacy --prod release_pkg produces a production-only dependency tree. The compiled dist, ecosystem.config.cjs and the deploy folder are copied in. The step fails if dist/server.js or the generated database client is missing from the bundle.
  6. Pack the bundle as a gzip tarball and test the archive.
  7. Make sure selected variables exist in the host’s shared env file. Values come from repository secrets. Empty values never overwrite existing ones.
  8. Upload the tarball with scp, compare SHA-256 checksums on both ends, and retry up to three times. A truncated upload can otherwise exit successfully and only fail later at extraction.
  9. On the host: extract into releases/<release id>, delete the tarball, remove tarballs older than a day, and run deploy/dev/release.sh.

Layout on the host

The scripts in deploy/dev

They ship inside every release and run on the host.

pm2 configuration

ecosystem.config.cjs defines one app named perform-api:
  • script: 'dist/server.js', run from the release root.
  • node_args: '--env-file=.env'. Node loads the symlinked shared env file itself.
  • One instance in fork mode. Do not switch to cluster mode. The schedulers assume a single process.
  • max_memory_restart: '1024M'. The steady working set is close to 500 MB. An earlier 512 MB ceiling made pm2 restart the API about every 30 seconds.
  • Logs go to shared/logs/api-out.log and shared/logs/api-err.log, merged and timestamped.

Graceful shutdown

server.ts handles SIGTERM and SIGINT. It closes every worker, the queue connection and the Prisma client, then closes the HTTP server. A timer forces the process to exit after 25 seconds. SIGUSR2, the restart signal used by nodemon, runs the same cleanup once and then re-raises the signal. The server sets keepAliveTimeout to 65 seconds and headersTimeout to 66 seconds. Keep the reverse proxy’s upstream idle timeout below 65 seconds so it never reuses a connection the API has already closed.

Things that must be true on the host

  • PORT=3031 in the runtime env. The health check and the reverse proxy expect it.
  • Exactly one reverse proxy hop in front of the API. app.ts sets trust proxy to 1 so req.ip is the real client address. With zero hops or more than one, the /v1 rate limiter buckets requests wrongly.
  • NODE_ENV=production.
  • TRAINEE_OTP_DEV_MODE unset or false.
  • SERVICE_AUTH_SECRET equal to the web app’s value.
  • Redis reachable. Readiness treats Redis as optional, but queues, schedulers and replay protection need it.

After a deploy

Then follow the Release checklist.