Bridge Training
Contributing

Deployment

How to build and deploy apps to production servers.

Overview

Apps are deployed to remote servers over SSH. Docker-based services (API, Docs) are transferred as images and run via docker compose. Static apps (App, Web, Player) are built locally and copied directly to the server's filesystem.

Each Docker-based app has a release script in scripts/ and a compose.yml in its app directory. Static apps have a release script only.

Deploy configuration

All deploy settings live in .env.deploy at the monorepo root (committed):

# API
API_HOST=bridge-training-vps
API_REMOTE_DIR=/home/almalinux/www/api
API_PORT=3002
API_IMAGE=bridge-training-api:latest

# Docs
DOCS_HOST=bridge-training-vps
DOCS_REMOTE_DIR=/home/almalinux/www/docs
DOCS_PORT=3003
DOCS_IMAGE=bridge-training-docs:latest

To override locally (e.g. different SSH host), create .env.deploy.local — it's gitignored via the *.local rule.

Environment variables can also be passed inline:

API_HOST=my-server pnpm release:api

Priority: inline env > .env.deploy.local > .env.deploy

Domains and services

ServiceDomainTypeSSH HostRemote Directory
Webbridge-training.comStaticbridge-training-static~/www/
Playerbridge-training.com/playerStaticbridge-training-static~/www/player/
Appapp.bridge-training.comStaticbt-legacy~/www/
Learnlearn.bridge-training.comStaticbridge-training-vps~/www/learn/
Player SSR (preprod)play.bridge-training.comDockerbridge-training-vps~/www/player-ssr/
APIapi.bridge-training.comDockerbridge-training-vps~/www/api/
Docsdocs.bridge-training.comDockerbridge-training-vps~/www/docs/

SSH hosts are aliases defined in ~/.ssh/config:

AliasServer
bridge-training-vpsMain VPS: every Docker service, reverse-proxied by Caddy
bt-legacyPrevious VPS, still serving the app until it moves to bridge-training-vps
bridge-training-staticOVH shared hosting: PHP and static files only, no Docker or long-running process

Build version (/version.json)

Every release target answers GET /version.json with the commit it was built from: no auth, HTTP 200, Content-Type: application/json, Cache-Control: no-cache.

{ "app": "player", "sha": "<40 lowercase hex chars>", "builtAt": "2026-10-15T09:12:03.123Z" }
  • app is the turbo package that was built, not the release target: both player hosts answer "player". A mismatch means the URL points at the wrong host.
  • sha is the full commit sha; builtAt is informational.
  • Extra fields are allowed. The app adds buildId (the first 7 chars of sha), which open tabs poll to show the reload toast; it never goes away.
TargetURLServed by
Apphttps://app.bridge-training.com/version.jsondist/version.json emitted by the Vite build, served no-cache by Caddy
Learnhttps://learn.bridge-training.com/version.jsondist/version.json emitted by the Vite build, Caddy file_server
APIhttps://api.bridge-training.com/version.jsonHono route at the host root, next to /health and outside BASE_PATH; inlined by tsup
Adminhttps://admin.bridge-training.com/version.jsonStatic Next route handler src/app/version.json/route.ts; the Clerk proxy skips dotted paths
Docshttps://docs.bridge-training.com/version.jsonStatic Next route handler; needs the Caddy exception below
Webhttps://bridge-training.com/version.jsonStatic Next route handler exported to out/version.json
Player SSRhttps://play.bridge-training.com/version.jsondist/version.json of the client build, served no-cache by the SSR host's static guard
Player legacyhttps://bridge-training.com/player/version.jsondist-legacy/version.json of the legacy mode build

BUILD_SHA

Builds read the sha once through readBuildInfo (@workspace/config/build-info): BUILD_SHA when set, else git rev-parse HEAD. Neither available, or a value that is not a full lowercase sha, fails the build: a build never ships an identity that diverges from its commit.

  • Every release script resolves BUILD_SHA="${BUILD_SHA:-$(git rev-parse HEAD)}" and exports it. The Docker builds (api, admin, player SSR) receive it as --build-arg BUILD_SHA, since the pruned build context has no .git.
  • BUILD_SHA is part of every turbo build hash, so a cache hit never restores a version.json from another commit when it is set. A package that overrides build in its own turbo.json re-lists it under env.
  • Next configs inline the payload as BUILD_INFO at build time and read nothing under next start, where the runner has neither git nor BUILD_SHA.
  • The short build id (release in Axiom, the app's buildId, the player's VITE_PLAYER_BUILD_ID) is the first 7 chars of the same sha.

Docs: Caddy exception

The docs host sits behind Caddy basicauth, which Caddy orders before handle whatever the text order, so /version.json needs its own handle and the existing basicauth moves into the fallback one. In the docs.bridge-training.com block of the Caddyfile on bridge-training-vps:

@version path /version.json
handle @version {
  reverse_proxy localhost:3003
}
handle {
  basicauth {
    # the existing credentials
  }
  reverse_proxy localhost:3003
}

Then caddy reload from ~/www. Until then the docs target answers 401.

Check a release

curl -s https://api.bridge-training.com/version.json

Release commands

Before releasing, ask the CLI what is pending and what changes. Both commands are read-only and offline; they read git, its tags and turbo in the checkout:

bt-cli release status                  # every target: delivered ref, pending commits, state
bt-cli release status api app --fetch  # some targets, after fetching origin develop and the tags
bt-cli release status --fail-if-pending  # exit 1 when a target is behind (CI guard)
bt-cli release diff api                # from the delivered ref to origin/develop
bt-cli release diff api --from <rev> --to <rev> --markdown  # a GitHub Release body
  • Delivered ref: the target's latest release tag, <target id>@YYYY-MM-DD.N (api@2026-10-15.1, player-ssr@2026-10-15.2): the UTC day of the release, then 1, 2, … per target that day, ordered by day then numerically (.10 after .9). Tags with the prefix that do not follow the format are ignored. A target without any tag shows untagged, which --fail-if-pending does not count as pending, and release diff needs --from for it. The hosts' /version.json is not read here: it is only the post-deploy check of bt-cli release <target> --apply, which makes the tags.
  • Pending: the first-parent commits of origin/develop since that tag that reach the target's package graph, as turbo sees the committed range (turbo ls --filter=...[<ref>...<to>]; uncommitted files never count). A change in apps/api is pending for api only; a change in a shared package such as packages/training is pending for every target whose dependency graph includes it. The root package.json and turbo.json affect every target, pnpm-lock.yaml only the packages whose dependencies moved, and files turbo does not track (scripts/, docs/, .github/) none: a commit that only edits scripts/release-api.sh is not pending for api.
  • Diff: the same commits grouped by conventional type and scope, one line per PR with the issues it closes, and the deploy notes: new packages/database/drizzle/*.sql migrations (the api container applies them at start), variables added to the target's env schemas, and changes to its Dockerfile, compose.yml or .dockerignore.

The targets are declared in apps/cli/src/lib/release/targets.ts. bt-cli release <target> is the entry point of a release, from the checkout to release (run pnpm cli release … inside a worktree):

bt-cli release api                 # dry-run: status, diff from the latest tag to HEAD, the plan and the tag, what --apply would refuse
bt-cli release api --apply         # after a confirmation: runs pnpm release:api, checks /version.json, then tags and pushes
bt-cli release api --apply --force # release even when nothing is pending
  • Refusals: --apply stops on a dirty working tree and on a target with nothing pending since its latest tag. --force lifts only the last one. An untagged target is released like any other: that release makes its first tag, and the dry-run says there is nothing to compare against. Releasing a branch other than origin/develop is allowed, with a warning.
  • Run: the target's root script below, from the monorepo root, with BUILD_SHA set to the HEAD sha. The script keeps its turbo gates, its e2e gate (SKIP_E2E passes through from the shell) and its deploy.
  • Check: the CLI then polls the target's /version.json (30 times, 2 s apart) until it serves the HEAD sha. A host that never does, or stops answering, exits 1 without a tag: the script already ran, so inspect the deploy before running it again.
  • Tag: once the host serves HEAD, the CLI creates the annotated tag <target id>@<UTC day>.<N> on HEAD, N being the next rank of that target that day, and pushes it to origin. A refused push exits 1 and leaves the local tag, with the command to push it. The next tag is computed from the tags of the clone: fetch them first (bt-cli release status --fetch) when someone else may have released the target today.
  • GitHub Releases are not created; bt-cli release diff <target> --markdown prints a release body.

The root scripts stay; they are what --apply runs:

pnpm release:admin    # Build + deploy admin back-office (Docker)
pnpm release:api      # Build + deploy API (Docker)
pnpm release:app      # Build + deploy main app (scp)
pnpm release:learn    # Build + deploy the learner space (rsync)
pnpm release:docs     # Build + deploy docs (Docker)
pnpm release:web      # Build + deploy marketing site (tar + ssh)

pnpm release:player-ssr     # Build + deploy the player SSR host (Docker)
pnpm release:player-legacy  # Build + deploy the player to the legacy PHP host

Testing Docker builds locally

The Translations app has a dedicated test script for validating Docker builds:

scripts/test-translations-docker.sh <env-file>              # Build only
scripts/test-translations-docker.sh <env-file> --run        # Build + run + smoke test
scripts/test-translations-docker.sh <env-file> --port 3005  # Custom port

How Docker releases work

The release:api and release:docs scripts follow the same pipeline:

  1. Build the Docker image locally
  2. Create the remote directory if needed (mkdir -p)
  3. Upload the compose.yml to the server
  4. Transfer the Docker image via SSH pipe (docker save | gzip | ssh docker load)
  5. Restart the service with docker compose up -d --force-recreate

Image transfer

docker save "$IMAGE" | gzip | ssh "$HOST" "gzip -d | docker load"

The image is streamed, compressed, and loaded on the remote server — nothing is written to disk intermediately.

Server prerequisites

Software

  • Docker with the Compose plugin (v2) — required for API and Docs
  • A web server (Nginx or Caddy) — serves static files and reverse-proxies Docker services
  • SSH access configured via ~/.ssh/config on the deploying machine

Remote directory structure

# Docker services
/home/almalinux/www/api/
├── compose.yml    # Uploaded by the release script
└── .env           # Manually created with production credentials

/home/almalinux/www/docs/
├── compose.yml
└── .env

# Static apps (served by web server)
~/www/web/        # Web (bridge-training.com)
~/www/app/        # App (app.bridge-training.com)
~/www/learn/      # Learn (learn.bridge-training.com)

Reverse proxy configuration

The web server must route each domain to the correct backend:

DomainTarget
bridge-training.comStatic files from ~/www/web/
app.bridge-training.comStatic files from ~/www/app/
learn.bridge-training.comStatic files from ~/www/learn/
api.bridge-training.comhttp://localhost:3002 (Docker)
docs.bridge-training.comhttp://localhost:3003 (Docker)

Static apps are SPAs — the web server should serve index.html for all non-file routes (try_files or equivalent).

Docs must be protected with HTTP basic authentication at the web server level (e.g. Nginx auth_basic or Caddy basicauth).

Docker Compose conventions

All Docker-based apps follow the same compose.yml conventions:

  • platform: linux/amd64 — the VPS is x86_64, dev machines are ARM (Apple Silicon). Images must be built for the target architecture.
  • healthcheck — each service exposes a GET /health route returning {"status":"ok"}. Docker pings it every 30s with 3 retries.
  • enable_ipv6: true — required because Supabase resolves to IPv6 only from the VPS network.
  • restart: unless-stopped — auto-restart on failure.

When adding a new Docker-based app, follow this structure (see apps/api/compose.yml and apps/docs/compose.yml as references).

Docker build (local)

To build Docker images locally without deploying:

pnpm docker:build:api     # Build API image
pnpm docker:build:docs    # Build docs image

App-specific notes

API

The API is bundled with tsup into a single JS file (dist/index.js). The Docker runner stage contains only Node.js and the bundle — no node_modules, no pnpm. Internal port 3001, exposed as 3002 on the host.

Docs

Next.js app running in a Docker container. Internal port 3000, exposed as 3003 on the host.

Admin

Next.js back-office in a Docker container on bridge-training-vps, served at admin.bridge-training.com. Internal port 3000, published on 127.0.0.1:4000 only: Caddy is the sole way in. Access is gated by Clerk and the account role stored in the database, so no extra web-server auth.

  • Clerk publishable key is baked in. NEXT_PUBLIC_* values are inlined into the client bundle at next build, so the release passes ADMIN_CLERK_PUBLISHABLE_KEY (from .env.deploy.local) as a build arg — changing it is a re-release.
  • Server config is read at start. The image builds with SKIP_ENV_VALIDATION=1; the remote ~/www/admin/.env carries everything apps/admin/src/lib/env.ts validates (Clerk secret, database, Supabase, GitHub App, translations, optional Stripe key, API_BASE_URL, APP_BASE_URL).
  • Health check hits the public GET /api/health.

App

Vite SPA deployed as static files via scp to ~/www/app/.

Learn

Vite SPA deployed as static files via rsync to ~/www/learn/ by pnpm release:learn (scripts/release-learn.sh; DRY_RUN=1 stops after the build). Deploy vars: LEARN_HOST, LEARN_REMOTE_DIR, LEARN_URL in .env.deploy; the build reads apps/learn/.env.production.local.

First deployment, once:

  1. DNS: learn.bridge-training.com pointing at the bridge-training-vps server (Caddy serves the static files).

  2. Caddy: a vhost serving the SPA, index.html for every non-file route:

    Caddyfile
    learn.bridge-training.com {
      root * /home/almalinux/www/learn
      encode zstd gzip
      try_files {path} /index.html
      file_server
    }

    then caddy reload from the Caddyfile's directory.

  3. Clerk: add https://learn.bridge-training.com to the production instance's allowed origins. The instance already serves the parent domain, so the session is shared with the App and the Player without a satellite domain.

  4. API: add https://learn.bridge-training.com to CORS_ORIGINS in the remote .env the container reads at runtime, then restart the container. CORS_ORIGINS lives only there, never in the committed apps/api/.env.production. The same list validates the checkout and portal return URLs.

  5. Stripe: the learner grid needs its portal configuration (plan_grids.stripe_portal_configuration_id) for the portal button to open.

  6. Known-viewer cookie: VITE_KNOWN_VIEWER_COOKIE_DOMAIN=.bridge-training.com in apps/learn/.env.production.local, so both players read the trace a signed-in learner leaves. The players leave it too for a signed-in player: the same value in the SSR host's remote .env and in apps/player/.env.legacy.local.

Player

Vite SPA deployed as static files via scp to ~/www/player/.

Player (legacy PHP host)

pnpm release:player-legacy ships the same Vite SPA to the legacy host, where the site document root is www and the bundle lives in www/player. What sets it apart from the SSR release:

  • Classic client build only — no server.tsx bundle, no Node host.
  • Assets live under /player/. The bundle is not at the document root, so apps/player/.env.legacy sets VITE_PLAYER_BASE_URL=/player/ (Vite base); hashed /assets plus the runtime /locales, /cards and /branding fetches all carry the prefix via import.meta.env.BASE_URL.
  • index.php is the entry. Years of shared links point at /player/index.php?dds=<code>, so the release copies the built index.html to index.php (the code is read client-side, the file needs no PHP) and deploys both. Both carry the ads provider's head tags — the Ezoic Gatekeeper CMP, which has to be in the served markup ahead of the bundle because @workspace/ads only injects sa.min.js itself at runtime. The tags and where they go belong to the package (@workspace/ads/ezoic/head-tags, plain ESM so build scripts can import it); apps/player/scripts/build-legacy-index.mjs only decides whether to call it.
  • No runtime env. Without a host process there is nothing to read a remote .env, so the build runs in Vite's legacy mode and bakes every public VITE_* value in from apps/player/.env.legacy (committed defaults) and apps/player/.env.legacy.local (gitignored keys, same names) — changing one is a re-release, not a restart.
  • No deal proxy either. The browser calls the upstream API directly (VITE_PLAYER_API_BASE_PATH), so the build has to bake in VITE_PLAYER_DEALS_API_KEY for the key-gated deal endpoints — public in that bundle by construction, and the reason the key is NOT set for the SSR host, which proxies instead. Put the value in apps/player/.env.legacy.local; without it the page loads and every deal request comes back 401.
Deploy varDefaultDescription
PLAYER_LEGACY_HOSTbridge-training-staticSSH alias of the legacy server
PLAYER_LEGACY_REMOTE_DIRwww/playerDeploy dir (under the www document root)
PLAYER_LEGACY_URL—Public URL, printed on success
PLAYER_LEGACY_ADS_HEAD_TAGStruefalse leaves the provider's head tags out

The deploy vars stop there: what the bundle carries is the player's business and lives in apps/player/.env.legacy(.local), next to the other env files of the app (see Environments). The Stripe links come from apps/player/.env, shared by every mode. DRY_RUN=1 stops after the build and the index.php generation, leaving the payload in apps/player/dist-legacy for review.

The deploy is an rsync without --delete: the remote dir is a document root that may hold files the bundle knows nothing about, and keeping superseded hashed assets lets sessions loaded from the previous release finish.

Web

Static marketing site deployed via tar over SSH to ~/www/web/.