Backend: backend/.env
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | - | Postgres URI, e.g. postgresql://postgres:postgres@localhost:5433/crabstack. A URL carrying sslmode= takes the TLS path described below. |
BACKEND_URL |
Yes | http://localhost:4000 |
Absolute public URL of this backend. The MCP OAuth flow (mcp-shared/) emits absolute issuer / authorization / token / registration URLs in its RFC 8414 + RFC 9728 discovery metadata, so this must match the host browsers see in production (e.g. https://api.yourdomain.com). The dev default works for local MCP OAuth. |
AUTH_SECRET |
Yes | - | Must match the frontend's AUTH_SECRET. NextAuth uses it for cookie signing. Generate with openssl rand -base64 32. |
ADMIN_MCP_API_KEY |
Yes | - | API key AI agents present on every request to /admin/mcp (the transport is stateless; no session store). |
INTERNAL_API_TOKEN |
Yes | - | Shared bearer used by the frontend's NextAuth adapter to call /internal/auth-adapter/*. Must match the frontend's INTERNAL_API_TOKEN. |
SMTP_FROM |
Yes | - | "From" address for outgoing backend emails. No fallback to SMTP_USER. |
STRIPE_SECRET_KEY |
No (enables billing) | - | Stripe secret key (sk_…). Unset → billing is disabled as a whole: /pricing 404s, billing UI hides, webhook route 404s. When set, STRIPE_WEBHOOK_SECRET becomes required. |
STRIPE_WEBHOOK_SECRET |
With billing | - | Webhook signing secret (whsec_…). Without it subscription state silently never updates. |
STRIPE_PRO_PRICE_ID |
With subscriptions | - | The recurring Price for the Pro plan. Not needed at boot: an app selling only one-time products through BILLING_PAYMENT_HOOK can leave it unset, and subscription checkout then fails with a generic message, naming the variable in the server log rather than to the customer. |
STRIPE_AUTOMATIC_TAX |
No | false |
true enables Stripe Tax on Checkout, but only after configuring Tax in the dashboard (otherwise checkout hard-fails). |
FRONTEND_URL |
No | http://localhost:3000 |
Absolute frontend origin for Checkout/Portal return URLs. Set in production when billing is enabled. |
ALLOWED_ORIGINS |
Yes (non-dev) | http://localhost:3000 |
Comma-separated CORS allowlist. Defaults to local frontend in dev; you must set it explicitly in every other environment. No wildcards. |
PORT |
No | 4000 |
Port NestJS listens on. |
NODE_ENV |
No | - | Set to production to disable the /docs Swagger UI (the /openapi.json endpoint is always exposed for CI). Production also flips secret validation from warn to throw. |
SMTP_HOST |
No | localhost |
SMTP server host. Defaults to MailDev. |
SMTP_PORT |
No | 1025 |
SMTP server port. Defaults to MailDev. |
SMTP_SECURE |
No | false |
Use TLS. Set to true for port 465 in production. |
SMTP_USER |
No | - | SMTP username. If unset, no auth is sent (works with MailDev). |
SMTP_PASS |
No | - | SMTP password. |
WORKER_CLAIM_STALE_MS |
No | 300000 (5 min) |
Worker claim TTL: rows in processing older than this are recoverable. |
USER_DELETION_MAX_ATTEMPTS |
No | 5 |
How many times the deletion worker retries one queue row before parking it for a human. The default is roughly 43 minutes of wall clock across the backoff, which is shorter than a Stripe incident can run; raise it if an outage would otherwise abandon every deletion in flight at once. Abandoned rows are listed by the admin_get_abandoned_deletions MCP tool and put back by admin_requeue_abandoned_deletions. A positive integer: zero or negative would stop the queue silently and Infinity would retry a poisoned row forever. |
TEST_DATABASE_URL |
No | - | Points the integration specs at a migrated Postgres. Unset, they skip, so a bare npm run test needs no database. ./verify.sh starts a throwaway one and sets this itself; CI points it at its Postgres service. Use a disposable database: these specs delete rows. |
MAGIC_LINK_MAX_ATTEMPTS |
No | 3 |
Max magic-link requests allowed per email (NFKC-normalized) inside the window before further requests are throttled. |
MAGIC_LINK_IP_MAX_ATTEMPTS |
No | 15 |
Max magic-link requests allowed per client IP (IPv4 address or IPv6 /64) inside the window, across all addresses, before further requests are throttled. Sized above the per-email cap so several people behind one NAT can sign in together; lower it to tighten email-relay abuse, raise it for a large office or campus. Applies only when the frontend sees a client IP from CLIENT_IP_HEADER or X-Forwarded-For (see Auth Flow). |
MAGIC_LINK_WINDOW_MS |
No | 300000 (5 min) |
Sliding window (ms) over which MAGIC_LINK_MAX_ATTEMPTS and MAGIC_LINK_IP_MAX_ATTEMPTS are counted. |
TRUST_PROXY |
No (required behind a proxy) | false |
true / false / integer hop count. Forwarded to Express's app.set('trust proxy', …). Must be set behind any LB/CDN/proxy. Without it, every request buckets under the proxy's IP and the per-IP rate limits (global 100/min throttler, MCP transport, MCP /register 10/min) collapse into one shared bucket: one client can lock everyone out. Express behind proxies explains hop counts; the address and subnet values it also lists are refused here. |
CLIENT_IP_HEADER |
No | - | Name of a request header the platform sets to the client's address on every request and overwrites when a client sends it: cf-connecting-ip behind Cloudflare, do-connecting-ip on DigitalOcean App Platform, fly-client-ip on Fly, x-real-ip behind an nginx configured to set it. When the header holds an IP, every per-IP limit above keys on it instead of the TRUST_PROXY answer; when it is missing or not an IP, the TRUST_PROXY answer applies. Set it where the proxy chain is not one trusted hop writing X-Forwarded-For, so the last hop is an ingress address many clients share. Setting it on a platform that does not overwrite the header lets every client pick its own address, which defeats every per-IP limit. Set the same value in the frontend. |
MCP_RATE_LIMIT_PER_MIN |
No | 1200 |
Per-IP-per-server budget for MCP transport endpoints (the RPC path: POST/GET/DELETE /admin/mcp and equivalent OAuth-flavored transports). Each MCP server has its own bucket per IP, so one server's traffic cannot exhaust another's budget. Bump up for a deployment that sees many concurrent clients behind one egress IP (a NAT'd office, a single dev running Claude Code + Claude Web + ChatGPT against the same server). The lower-volume buckets (60/min discovery, 10/min /register, 30/min OAuth /authorize, 60/min OAuth /token) are not env-tunable; they're sized for "once per client install" (or once per human consent) semantics. |
Which of these stop boot: What refuses boot.
Run npm run migrate after first install to create tables.
Database TLS
Worth knowing before you point this at a managed provider, because the default is a deliberate trade-off rather than an oversight.
A DATABASE_URL with no sslmode= is used exactly as given. The bundled
compose stack is in this case: the database is on a private network with the
app, and no TLS is involved.
A DATABASE_URL carrying sslmode= (managed providers commonly emit
?sslmode=require) takes a different path. The parameter is stripped and the
connection is made with ssl: { rejectUnauthorized: false }. That is because
pg-connection-string reads require as strict CA verification, which fails
against the self-signed chains most managed providers use, so honouring it
literally would refuse to connect to them out of the box.
The consequence: that connection is encrypted but the server is not authenticated. It protects against passive interception, not against an attacker positioned on the network path between the app and the database. On a provider's private network that is usually an acceptable risk. Decide whether it is for you.
To verify the server instead, pass your provider's CA and flip the flag:
ssl: { rejectUnauthorized: true, ca: <your provider's CA PEM> }
This is a code edit, not a setting. The certificate, where you store it and
when it rotates are specific to your provider, so the template does not invent
an environment variable for it. Change it in one place,
backend/src/knex/build-connection.ts: both the Nest runtime and
knexfile.ts import that function, and the Dockerfile copies it into the
runtime image so npm run migrate uses the same rules there.