Deployment
CrabStack is two deployable units that talk to each other over HTTP:
- Backend (
backend/): a long-running NestJS process. It needs a Postgres database and a host that keeps a Node process alive, because the cron workers run inside the process, not as external scheduled functions. - Frontend (
frontend/): Next.js 16. Runs as a Node server or on a serverless platform.
Nothing here is tied to a particular host. If it runs a persistent Node process and can reach a Postgres, it works. The fastest path is the deploy kit in deploy/: one VPS from any provider, Docker Compose, and Caddy for automatic HTTPS. This guide leads with that; the generic requirements for running anywhere else follow.
The 10-minute VPS path
What you need: a VPS from any provider (2 GB RAM is comfortable; the Next.js image build is the hungriest step), a domain, and an SMTP provider if you want email sign-in.
The kit is three files in deploy/:
docker-compose.prod.yml- Postgres, backend, frontend, and Caddy. Only Caddy binds host ports (80/443); everything else stays on the compose-internal network.Caddyfile- reverse proxy plus automatic HTTPS via ACME..env.prod.example- every variable the stack needs, in one place.
1. Provision the box
Any Linux VPS with Docker Engine and Compose v2 installed, ports 80 and 443 open, root or sudo access.
2. Point DNS at it
Two A records to the box's IP: yourapp.com and api.yourapp.com. The API gets its own subdomain (see One domain, two origins). Wait for both to resolve before starting the stack: certificate issuance needs working DNS.
3. Clone and configure
git clone <your-repo-url> app && cd app
cp deploy/.env.prod.example deploy/.env.prod
Fill in deploy/.env.prod. The file documents every variable; the short version:
DOMAINandACME_EMAIL: your domain, and the email the certificate authority uses for expiry notices.POSTGRES_PASSWORD, plus the same password insideDATABASE_URL.AUTH_SECRET,INTERNAL_API_TOKEN,ADMIN_MCP_API_KEY: generate each withopenssl rand -base64 32. Boot-time validation rejects the placeholders, so a half-filled file fails loudly instead of running insecure.- SMTP credentials and/or OAuth client credentials: at least one sign-in method.
Every compose command takes the same two flags, so alias them once:
alias dcp='docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml'
4. Build, migrate, start
dcp build
dcp run --rm backend yarn migrate
dcp up -d
Migrations are an explicit step on purpose: nothing auto-migrates on boot. The run --rm line starts Postgres (waiting for its healthcheck), runs the Knex migrations in a one-off container, and exits. Re-running an applied migration is a no-op.
The same three commands are the whole update story too: git pull, then build, migrate, up.
Caddy requests certificates as soon as it starts. Give it a few seconds, then open https://yourapp.com.
5. Seed data (optional)
The seed script runs from TypeScript source, which the production image does not ship, so mount the source into the one-off container:
dcp run --rm \
-v "$PWD/backend/src:/app/src" \
-v "$PWD/backend/tsconfig.json:/app/tsconfig.json" \
backend yarn seed
One domain, two origins
Caddy serves two origins from the one box:
| Origin | Upstream |
|---|---|
https://yourapp.com |
frontend (frontend:3000) |
https://api.yourapp.com |
backend (backend:4000) |
The backend gets a subdomain rather than a path prefix because its routes live at the root of its own origin: /openapi.json, /admin/mcp, and the MCP OAuth discovery documents under /.well-known/ all assume no prefix, and BACKEND_URL bakes absolute URLs into the discovery metadata. A path prefix would mean rewriting all of that; a subdomain means rewriting nothing. The browser calls the API cross-origin via NEXT_PUBLIC_API_URL, which is exactly what the backend's ALLOWED_ORIGINS CORS allowlist is for.
The compose file derives all the public URLs from DOMAIN, so the origins, CORS allowlist, and OAuth discovery URLs cannot drift apart.
Managed Postgres instead
If you would rather not run the database yourself, any managed Postgres 13+ works. Set DATABASE_URL in deploy/.env.prod to the provider's connection string, then delete the postgres service and the backend's depends_on block from docker-compose.prod.yml. POSTGRES_PASSWORD goes unused. See Database for the notes that apply either way.
Running it anywhere else
The kit is convenience, not a requirement. What each piece needs:
| Piece | Requirement |
|---|---|
| Backend | A persistent Node 20+ process (node dist/main.js). Not a serverless function: cron workers run in-process every 30s and hourly. Outbound network for SMTP. |
| Frontend | A Node 20+ build, then either next start or a serverless Next.js host. Env vars settable at build and runtime. |
| Postgres | Any Postgres 13+ (the migrations use only gen_random_uuid() from pgcrypto, default-on in 13+). Self-hosted or managed; both are fine. |
| SMTP | Any SMTP provider. |
Backend. Build with yarn install && yarn build; start with yarn migrate && node dist/main.js under something that keeps it alive and restarts it (systemd, a Docker restart policy, pm2). The in-process cron is why you want a supervised, always-on process, not a scale-to-zero one. Run migrations as part of the deploy, before the process accepts traffic. Set TRUST_PROXY to the number of proxies in front of it; without it, per-IP rate limits collapse into the proxy's single IP.
MCP OAuth and multiple replicas. The MCP OAuth flow's DCR client registry and short-lived auth codes (mcp-shared/mcp-oauth.internals.ts) live in in-process Maps, not Postgres, so the handshake breaks nondeterministically behind more than one backend replica (a code issued on one replica can't be redeemed on another). The stateless MCP transport itself has no such limit - it's the OAuth layer specifically. Stay on a single backend replica until DCR registrations and auth codes are persisted to Postgres.
Frontend. yarn build, then yarn start behind a TLS-terminating reverse proxy, or hand it to a serverless Next.js host. NextAuth reads env at runtime, while NEXT_PUBLIC_* is baked in at build, so set both (the Dockerfile takes NEXT_PUBLIC_API_URL and NEXT_PUBLIC_SITE_URL as build args for this reason). proxy.ts runs as edge middleware for the CSP nonce and works either way.
Key frontend env vars (full list in Environment Variables):
NEXT_PUBLIC_API_URL: the backend's public origin (e.g.https://api.yourapp.com). Required in production; falls back tohttp://localhost:4000if unset.NEXT_PUBLIC_SITE_URL: the frontend origin (e.g.https://yourapp.com). Required in production to prevent host-header injection on magic-link emails.AUTH_URL: the frontend origin, typically the same asNEXT_PUBLIC_SITE_URL. Required in production; NextAuth uses it for CSRF and post-verification redirects. Unset, magic-link verification silently redirects tohttp://localhost:3000.INTERNAL_API_URL: the same asNEXT_PUBLIC_API_URL, or a private-network address if your backend exposes one.INTERNAL_API_TOKENandAUTH_SECRET: must match the backend's values.
Set NODE_ENV=production on both halves. Boot-time validation throws on placeholder secrets, so a misconfigured deploy fails loudly instead of coming up insecure.
Database
Run migrations on every deploy (yarn migrate in backend/). Knex is idempotent: re-running an applied migration is a no-op.
Any Postgres 13+ works, on the same box or a separate instance. Two notes:
- Keep the database close to the backend (same host or same region); the app makes many small queries.
- If your Postgres sits behind a connection pooler, use the pooled connection string (managed providers usually hand you one, often with
sslmode=require). A single box rarely needs a pooler, but you can put PgBouncer in front if you ever do.
SMTP
The frontend sends magic-link emails (synchronous, via NextAuth). The backend sends bulk and transactional emails (queue plus worker). Both take the same kind of SMTP credentials, so point them at one provider: SMTP_* on the backend, EMAIL_SERVER_* plus EMAIL_FROM on the frontend. Verify your sending domain (SPF and DKIM) before sending to real addresses.
Health checks
Point any platform or load-balancer health probe at /robots.txt, not /. The / route renders dynamically (the root layout reads request headers for the CSP nonce), so every probe would trigger a full server render. /robots.txt is statically prerendered (see frontend/app/robots.ts), so it is the cheapest "is the process up?" answer.
OAuth callback URLs
When you set up GitHub or Google OAuth apps, the redirect URI is:
- GitHub:
https://yourapp.com/api/auth/callback/github - Google:
https://yourapp.com/api/auth/callback/google
Add both your production URL and http://localhost:3000/api/auth/callback/... for local development. Some providers (Google) reject localhost on a production OAuth client, so create a separate dev client for local work.
Pre-launch checklist
Before pointing real users at the deployment:
-
NODE_ENV=productionset on backend and frontend (the kit sets it). -
NEXT_PUBLIC_SITE_URL,AUTH_URL, andNEXT_PUBLIC_API_URLall set (frontend boot-time validation enforces this; they otherwise fall back tolocalhost). The kit derives all three fromDOMAIN. -
AUTH_SECRET,ADMIN_MCP_API_KEY,INTERNAL_API_TOKENall rotated from.envplaceholders (boot-time validation enforces this). -
AUTH_SECRETmatches between frontend and backend. -
INTERNAL_API_TOKENmatches between frontend and backend. -
ALLOWED_ORIGINSon backend lists your frontend origin only (no wildcards; the kit derives it fromDOMAIN). -
TRUST_PROXYset to the number of proxies in front of the backend (the kit sets1for Caddy). - OAuth apps have production callback URLs configured.
- If billing is enabled: production webhook endpoint subscribed to the six events listed in Billing,
FRONTEND_URLset, Customer Portal enabled in the Stripe dashboard. - Sending domain verified with the SMTP provider (SPF and DKIM).
-
yarn migrateruns as part of the deploy step. - Test magic-link sign-in end-to-end on the live deployment before announcing.
Related
- Environment Variables: full env-var reference.
- Auth Flow: how OAuth and magic-link sign-in work.
- Architecture: what each process owns.