Deployment

Inbox is a NestJS service on Fastify, deployed as a single Node process from dist/main.js. The repository is a Yarn 1 workspace built by Turbo. deploy.forge.sh in the repo root is the authoritative deployment script; this page is that script explained.

The ordered steps

Order matters at three points, and getting any of them wrong fails in a way that does not obviously name its cause.

1. Source .env before anything else

set -a; source .env; set +a

Prisma needs INBOX_DATABASE_URL during the deploy itself, not just at runtime, and nothing in the app loads .env on its owndotenv is a devDependency that is never imported.

2. Install with yarn against the lockfile

yarn install --frozen-lockfile

The repo is a yarn@1.22.22 workspace and yarn.lock is authoritative. Do not substitute npm or pnpm: the workspace protocol resolution differs and the lockfile will not be honoured.

3. Generate the Prisma client BEFORE building

yarn workspace @~inbox/service exec prisma generate

Prisma 6 does not auto-generate. Without this step @prisma/client resolves to its empty stub, and the TypeScript build fails with roughly 133 errors of the form Prisma.Decimal has no exported member, Prisma.Sql has no exported member, Prisma.InputJsonValue has no exported member. The error text names TypeScript, not Prisma, so it reads as a broken build rather than a missing step.

apps/service's postinstall also runs prisma generate as a safety net, but the explicit call is what makes the ordering guaranteed rather than incidental.

4. Build

yarn build

Turbo orders this via ^build: the three workspace packages — @~inbox/types, @~inbox/sdk, @~inbox/constraints — build first, then @~inbox/service. Building the service alone produces Cannot find module '@~inbox/types'.

apps/site builds in the same pass, which is what puts the documentation into the release. Skip it and /docs is served as 404s while the API is perfectly healthy.

5. Apply migrations with migrate deploy

yarn workspace @~inbox/service exec prisma migrate deploy

Never prisma db push in production. The repo has a real prisma/migrations/ history — including hand-written raw SQL for the partial unique indexes that enforce the one-open-ticket and ticket-number invariants. db push diffs the schema against the database and applies the difference without recording it, desynchronising the migration history; the next migrate deploy then either fails or re-applies work already done. migrate deploy applies only what is pending, in order, and is safe to run on every deploy including the first.

6. Start under PM2

pm2 start "$PM2_CONF"
pm2 save

Run as a single fork (exec_mode: fork, instances: 1), not cluster. The service is Fastify and every cluster worker would try to bind INBOX_PORT, so workers 2..N crash with EADDRINUSE in a restart storm.

Use pm2 delete followed by pm2 start, not pm2 reload. A reload keeps a process's original script path, so it cannot migrate a stale process onto dist/main.js — which matters when an earlier deploy left a config pointing somewhere else.

The whole sequence is idempotent: install, generate and build are repeatable, migrate deploy applies only what is pending, and delete-then-start swaps cleanly to the new release.

Environment

Two variables are hard-required — the process refuses to boot without them, with Missing required env var <NAME>:

Variable Constraint
INBOX_KEK Exactly 32 bytes (64 hex characters)
INBOX_REALTIME_JWT_SECRET At least 32 characters

Everything else degrades rather than failing, which is the sharper edge:

Variable If unset
INBOX_DATABASE_URL Silent fallback to in-memory stores. The service works and persists nothing
INBOX_REDIS_URL Queues fall back to in-memory twins — see Queues and workers
INBOX_OPERATOR_APP_SLUG No human gets operator access, silently
INBOX_ACCOUNTS_BASE_URL / INBOX_ACCOUNTS_SERVICE_KEY / INBOX_ACCOUNTS_APP_SLUG Credential introspection cannot resolve
INBOX_PUBLIC_URL Provider webhook registration has no callback address to hand upstream
INBOX_PORT Defaults to 3000

The complete list is on Configuration, generated from source.

Health checks

curl -s $INBOX/v1/ping     # {"ok":true} — liveness only
curl -s $INBOX/v1/health
{
  "status": "ok",
  "env": "production",
  "backends": { "database": true, "redis": true, "aggregator": true }
}

health reports only whether each backend is wired — never counts, names or connection strings. It is unauthenticated, so it must not be a reconnaissance surface.

If database is false, the deployment is broken even though everything responds. INBOX_DATABASE_URL did not reach the process, and the service is running on in-memory stores: writes succeed, reads work within the process, and everything vanishes on restart. This is the single most important line of the health check.

Point your load balancer at /v1/ping and your alerting at /v1/health.

The reverse proxy must upgrade WebSocket

The realtime gateway attaches its own upgrade handler at /v1/realtime, bypassing Nest's guard pipeline. Your proxy has to forward the upgrade:

location /v1/realtime {
    proxy_pass http://127.0.0.1:6999;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
}

A proxy that does not upgrade breaks realtime silently while everything else works. The HTTP API is fine, the token mints fine, the client connects — and no events ever arrive. It presents as "the inbox does not live-update", which nobody traces to nginx quickly. If WebSocket cannot be made to work, SSE is the fallback; see Server-sent events.

The proxy must also raise its own body limit to match Fastify's 25 MB, or the historical import endpoints 413 at the proxy before reaching the service.