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:latestTo 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:apiPriority: inline env > .env.deploy.local > .env.deploy
Domains and services
| Service | Domain | Type | SSH Host | Remote Directory |
|---|---|---|---|---|
| Web | bridge-training.com | Static | bridge-training-static | ~/www/ |
| Player | bridge-training.com/player | Static | bridge-training-static | ~/www/player/ |
| App | app.bridge-training.com | Static | bt-legacy | ~/www/ |
| Learn | learn.bridge-training.com | Static | bridge-training-vps | ~/www/learn/ |
| Player SSR (preprod) | play.bridge-training.com | Docker | bridge-training-vps | ~/www/player-ssr/ |
| API | api.bridge-training.com | Docker | bridge-training-vps | ~/www/api/ |
| Docs | docs.bridge-training.com | Docker | bridge-training-vps | ~/www/docs/ |
SSH hosts are aliases defined in ~/.ssh/config:
| Alias | Server |
|---|---|
bridge-training-vps | Main VPS: every Docker service, reverse-proxied by Caddy |
bt-legacy | Previous VPS, still serving the app until it moves to bridge-training-vps |
bridge-training-static | OVH 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" }appis 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.shais the full commit sha;builtAtis informational.- Extra fields are allowed. The app adds
buildId(the first 7 chars ofsha), which open tabs poll to show the reload toast; it never goes away.
| Target | URL | Served by |
|---|---|---|
| App | https://app.bridge-training.com/version.json | dist/version.json emitted by the Vite build, served no-cache by Caddy |
| Learn | https://learn.bridge-training.com/version.json | dist/version.json emitted by the Vite build, Caddy file_server |
| API | https://api.bridge-training.com/version.json | Hono route at the host root, next to /health and outside BASE_PATH; inlined by tsup |
| Admin | https://admin.bridge-training.com/version.json | Static Next route handler src/app/version.json/route.ts; the Clerk proxy skips dotted paths |
| Docs | https://docs.bridge-training.com/version.json | Static Next route handler; needs the Caddy exception below |
| Web | https://bridge-training.com/version.json | Static Next route handler exported to out/version.json |
| Player SSR | https://play.bridge-training.com/version.json | dist/version.json of the client build, served no-cache by the SSR host's static guard |
| Player legacy | https://bridge-training.com/player/version.json | dist-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_SHAis part of every turbobuildhash, so a cache hit never restores aversion.jsonfrom another commit when it is set. A package that overridesbuildin its ownturbo.jsonre-lists it underenv.- Next configs inline the payload as
BUILD_INFOat build time and read nothing undernext start, where the runner has neither git norBUILD_SHA. - The short build id (
releasein Axiom, the app'sbuildId, the player'sVITE_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.jsonRelease 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 (.10after.9). Tags with the prefix that do not follow the format are ignored. A target without any tag showsuntagged, which--fail-if-pendingdoes not count as pending, andrelease diffneeds--fromfor it. The hosts'/version.jsonis not read here: it is only the post-deploy check ofbt-cli release <target> --apply, which makes the tags. - Pending: the first-parent commits of
origin/developsince 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 inapps/apiis pending forapionly; a change in a shared package such aspackages/trainingis pending for every target whose dependency graph includes it. The rootpackage.jsonandturbo.jsonaffect every target,pnpm-lock.yamlonly the packages whose dependencies moved, and files turbo does not track (scripts/,docs/,.github/) none: a commit that only editsscripts/release-api.shis not pending forapi. - 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/*.sqlmigrations (the api container applies them at start), variables added to the target's env schemas, and changes to itsDockerfile,compose.ymlor.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:
--applystops on a dirty working tree and on a target with nothing pending since its latest tag.--forcelifts 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 thanorigin/developis allowed, with a warning. - Run: the target's root script below, from the monorepo root, with
BUILD_SHAset to the HEAD sha. The script keeps its turbo gates, its e2e gate (SKIP_E2Epasses 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> --markdownprints 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 hostTesting 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 portHow Docker releases work
The release:api and release:docs scripts follow the same pipeline:
- Build the Docker image locally
- Create the remote directory if needed (
mkdir -p) - Upload the
compose.ymlto the server - Transfer the Docker image via SSH pipe (
docker save | gzip | ssh docker load) - 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/configon 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:
| Domain | Target |
|---|---|
bridge-training.com | Static files from ~/www/web/ |
app.bridge-training.com | Static files from ~/www/app/ |
learn.bridge-training.com | Static files from ~/www/learn/ |
api.bridge-training.com | http://localhost:3002 (Docker) |
docs.bridge-training.com | http://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 aGET /healthroute 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 imageApp-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 atnext build, so the release passesADMIN_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/.envcarries everythingapps/admin/src/lib/env.tsvalidates (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:
-
DNS:
learn.bridge-training.compointing at thebridge-training-vpsserver (Caddy serves the static files). -
Caddy: a vhost serving the SPA,
index.htmlfor 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 reloadfrom the Caddyfile's directory. -
Clerk: add
https://learn.bridge-training.comto 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. -
API: add
https://learn.bridge-training.comtoCORS_ORIGINSin the remote.envthe container reads at runtime, then restart the container.CORS_ORIGINSlives only there, never in the committedapps/api/.env.production. The same list validates the checkout and portal return URLs. -
Stripe: the
learnergrid needs its portal configuration (plan_grids.stripe_portal_configuration_id) for the portal button to open. -
Known-viewer cookie:
VITE_KNOWN_VIEWER_COOKIE_DOMAIN=.bridge-training.cominapps/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.envand inapps/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.tsxbundle, no Node host. - Assets live under
/player/. The bundle is not at the document root, soapps/player/.env.legacysetsVITE_PLAYER_BASE_URL=/player/(Vitebase); hashed/assetsplus the runtime/locales,/cardsand/brandingfetches all carry the prefix viaimport.meta.env.BASE_URL. index.phpis the entry. Years of shared links point at/player/index.php?dds=<code>, so the release copies the builtindex.htmltoindex.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/adsonly injectssa.min.jsitself 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.mjsonly 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'slegacymode and bakes every publicVITE_*value in fromapps/player/.env.legacy(committed defaults) andapps/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 inVITE_PLAYER_DEALS_API_KEYfor 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 inapps/player/.env.legacy.local; without it the page loads and every deal request comes back 401.
| Deploy var | Default | Description |
|---|---|---|
PLAYER_LEGACY_HOST | bridge-training-static | SSH alias of the legacy server |
PLAYER_LEGACY_REMOTE_DIR | www/player | Deploy dir (under the www document root) |
PLAYER_LEGACY_URL | — | Public URL, printed on success |
PLAYER_LEGACY_ADS_HEAD_TAGS | true | false 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/.