The web repository mirrors the backend. It has two GitHub Actions deployment workflows. Server addresses and credentials come from repository secrets and are not documented here.
The Docker workflow builds with NEXT_PUBLIC_SAAS_URL and NEXT_PUBLIC_SITE_URL set to the production web URL and CORE_API_URL set to the production API URL. A push to dev is a production deployment of the web app. The web CI workflow does not run on pushes to dev.
frontend/docs/DEPLOYMENT.md says the pm2 workflow runs on push to dev and that the app uses its own database. Both statements are out of date. The pm2 workflow is manual, and the web app has no database connection at runtime.

The standalone build

apps/saas/next.config.ts sets output: "standalone" and outputFileTracingRoot to the monorepo root. next build --webpack writes a self-contained server to apps/saas/.next/standalone, with a runnable apps/saas/server.js, the traced workspace packages and a trimmed node_modules. Standalone output does not include static assets. Every packaging step copies apps/saas/.next/static and apps/saas/public next to the server. The production server listens on port 3043 on 0.0.0.0. A reverse proxy terminates TLS in front of it.

The marketing site ships inside the app

apps/marketing is a second Next.js app built with output: "export" and assetPrefix: "/mkt". Its static export is copied into the web app’s public folder at build time: apps/saas/proxy.ts rewrites a request for / to /mkt/index.html when the visitor has no session cookie. Signed-in users and every other path are untouched. In development the same rewrite points at the marketing dev server on port 3001 instead. pnpm build at the repository root builds both apps (turbo build --filter=saas --filter=marketing).

The trainee preview bundle

The web app also serves a web export of the mobile app under /trainee-preview. It is produced in the mobile repository with pnpm preview:export, which writes into frontend/apps/saas/public/trainee-preview. Neither deploy workflow runs that export. Both build only what is in the web repository, so the preview a coach sees is whatever bundle sits in apps/saas/public/trainee-preview in the commit being deployed. Re-export it when a mobile change should be visible in the preview, and check with git status that the files are tracked before you rely on it. Line 54 of the web .gitignore reads apps/saas/public/trainee-preview/packages/database/prisma/generated/, which looks like two entries joined by a missing line break, so it is not clear from the file whether the folder was meant to be ignored. See Mobile builds.

Docker image workflow

The Dockerfile

1

Builder stage

On node:22-bookworm-slim with Corepack. Copies the repository and runs pnpm install --frozen-lockfile.Build arguments become environment variables for the build: NEXT_PUBLIC_SAAS_URL, NEXT_PUBLIC_SITE_URL, CORE_API_URL and CORE_API_SERVICE_ID (default web). DATABASE_URL, BETTER_AUTH_SECRET and SERVICE_AUTH_SECRET default to clearly named build-time placeholders that are not used at runtime.Runs pnpm build, then builds the marketing export and copies it into apps/saas/public as described above.
2

Runner stage

A clean node:22-bookworm-slim. Copies the standalone tree, the static assets and the public folder. Sets NODE_ENV=production, PORT=3043, HOSTNAME=0.0.0.0. Runs node apps/saas/server.js.
NEXT_PUBLIC_* values are compiled into the client bundle. Changing one means rebuilding the image. Server-only values such as SERVICE_AUTH_SECRET are read at runtime from the container’s environment, so the real values must be supplied by the host’s Compose file.

The workflow

  1. Build the image with the three production build arguments and push it to the GitHub Container Registry, tagged latest and with the commit SHA.
  2. Connect to the host over SSH and run docker compose pull, docker compose up -d and docker image prune -f in the application directory.
There is no database step. The web app owns no schema. To roll back, point the Compose service at the SHA tag of the last good commit and run docker compose up -d, or revert on dev.

pm2 release workflow

dev.yml builds on the runner with Node 24.
  1. pnpm install --frozen-lockfile.
  2. Derive NEXT_SERVER_ACTIONS_ENCRYPTION_KEY from the auth secret with a fixed prefix and SHA-256, mask it, and export it for the build. Without a stable key, Server Action ids change on every build and open browser tabs break after a release. DEPLOYMENT_VERSION is set to the commit SHA and feeds deploymentId in next.config.ts.
  3. pnpm type-check, then pnpm build.
  4. Package release_pkg: the standalone tree, .next/static, public, the marketing export, the deploy folder and ecosystem.config.cjs. Pack and test the tarball.
  5. Upload with scp, verify the SHA-256 checksum on the host, retry up to three times.
  6. On the host: extract into releases/<release id> and run deploy/dev/release.sh.
The host layout and the four scripts (release.sh, rollback.sh, health-check.sh, prune.sh) work exactly as in the backend. See Deploying the backend. The differences: release.sh also deletes a pm2 app named byperform-marketing if one exists. Marketing used to run as its own process and now ships inside the web app.

Runtime environment

The running server needs at least: See Web and mobile environment variables for the full list.

Deploy order with the backend

The web app calls the API on every page. When a change needs both:
  1. Deploy the backend first, with the new endpoint or field added in a backward compatible way.
  2. Deploy the web app.
  3. Remove the old endpoint or field in a later backend release, once the mobile app no longer uses it either.
A push to dev in both repositories at the same moment starts two independent workflows with no ordering between them.

After a deploy

The second request goes through the /api proxy to the Hono app in the backend and returns OK. It proves the web server can reach the API. Then sign in and load a trainee list, which exercises the signed /v1/web path.