What gets built
Both mechanisms produce the same program: the compiled API atapps/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
- Check out the repository and set up Docker Buildx.
- Log in to the GitHub Container Registry with the workflow token.
- Build the image and push it with two tags:
latestand the commit SHA. Layers are cached in the GitHub Actions cache. - Connect to the host over SSH with a key from secrets and run, in the application directory:
docker loginto the registry,docker compose pull,- a one-off container that runs
prisma db pushagainstpackages/db/prisma/schema.prisma, docker compose up -d,docker image prune -f.
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.
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 rundocker 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
- Node 24 and pnpm,
pnpm install --frozen-lockfile. pnpm db:generate,pnpm typecheck,pnpm build.- Open an SSH tunnel to the host’s Postgres, read the runtime
DATABASE_URLfrom the host’s shared env file, rewrite its host to the tunnel, mask it in the logs, and runprisma db pushfor@repo/database. - Compute a release id from the UTC time, in the form
YYYYMMDDTHHMMSSZ. - Build the bundle:
pnpm --filter=@perform/core-api deploy --legacy --prod release_pkgproduces a production-only dependency tree. The compileddist,ecosystem.config.cjsand thedeployfolder are copied in. The step fails ifdist/server.jsor the generated database client is missing from the bundle. - Pack the bundle as a gzip tarball and test the archive.
- Make sure selected variables exist in the host’s shared env file. Values come from repository secrets. Empty values never overwrite existing ones.
- 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. - On the host: extract into
releases/<release id>, delete the tarball, remove tarballs older than a day, and rundeploy/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
forkmode. 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.logandshared/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=3031in the runtime env. The health check and the reverse proxy expect it.- Exactly one reverse proxy hop in front of the API.
app.tssetstrust proxyto1soreq.ipis the real client address. With zero hops or more than one, the/v1rate limiter buckets requests wrongly. NODE_ENV=production.TRAINEE_OTP_DEV_MODEunset orfalse.SERVICE_AUTH_SECRETequal to the web app’s value.- Redis reachable. Readiness treats Redis as optional, but queues, schedulers and replay protection need it.