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 own — dotenv 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.