Environment Variables
Both projects (backend/, frontend/) ship .env.example files. Copy each to .env and fill in your values.
Boot-time validation. The backend refuses to start in production if any of
AUTH_SECRET,ADMIN_MCP_API_KEY, orINTERNAL_API_TOKENare unset, shorter than 32 characters, or match the placeholder patterns shipped in.env.example(e.g.generate-...,docker-dev-...,...-change-in-production). In development the same check warns to stderr instead of throwing. Generate real values withopenssl rand -base64 32.
Backend: backend/.env
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | - | Postgres URI, e.g. postgresql://postgres:postgres@localhost:5432/crabstack |
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. Boot fails if unset (no silent 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, the two vars below become required (partial config fails boot). |
STRIPE_WEBHOOK_SECRET |
With billing | - | Webhook signing secret (whsec_…). Without it subscription state silently never updates, so boot fails fast instead. |
STRIPE_PRO_PRICE_ID |
With billing | - | The recurring Price for the Pro plan. |
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. |
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_WINDOW_MS |
No | 300000 (5 min) |
Sliding window (ms) over which MAGIC_LINK_MAX_ATTEMPTS is counted. |
DATABASE_CA_CERT |
No | - | PEM-encoded CA certificate for verified TLS database connections. When set, knex/build-connection.ts can switch to rejectUnauthorized: true with this CA instead of the relaxed managed-Postgres default. Leave unset for local dev or providers with a self-signed CA. |
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. See Express behind proxies for picking the right value. |
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 (see #14). 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. |
Run yarn migrate after first install to create tables.
Frontend: frontend/.env
| Variable | Required | Default | Description |
|---|---|---|---|
NEXT_PUBLIC_API_URL |
Required in production | - | Public REST API base URL (e.g. http://localhost:4000). Used by lib/api.ts's api client and shown to users (e.g. the MCP install command). Falls back to http://localhost:4000 if unset, so boot fails fast when NODE_ENV=production. |
NEXT_PUBLIC_SITE_URL |
Required in production | - | Public origin of this Next.js app (e.g. https://yourapp.com). Magic-link emails pin their host to this value to prevent host-header injection. Boot fails fast if unset when NODE_ENV=production. Used for sitemap, robots.txt, OG meta. |
AUTH_URL |
Required in production | - | NextAuth's own origin (e.g. https://yourapp.com, typically =NEXT_PUBLIC_SITE_URL). Drives NextAuth v5's CSRF callback-url cookie and post-verification redirects; when unset it falls back to http://localhost:3000 in a path trustHost can't reach, silently breaking magic-link verification. Boot fails fast if unset when NODE_ENV=production. |
AUTH_SECRET |
Yes | - | NextAuth signing secret. Must match the backend's AUTH_SECRET. Generate with openssl rand -base64 32 (or npx auth secret). |
API_URL_SERVER |
No | NEXT_PUBLIC_API_URL |
Server-side override for the public API base URL. Set it when the browser-facing address doesn't resolve from inside the server's network (Docker service hostname, private networking). Used by RSC reads, serverApi(), and the middleware admin probe; never shipped to the browser. |
INTERNAL_API_URL |
Yes | - | Server-only base URL for backend internal endpoints. Same host as NEXT_PUBLIC_API_URL in dev; can differ in prod (private network address). |
INTERNAL_API_TOKEN |
Yes | - | Shared bearer for /internal/auth-adapter/*. Must match the backend's INTERNAL_API_TOKEN. |
EMAIL_SERVER_HOST |
Yes (for magic-link) | localhost |
SMTP host for magic-link sends. MailDev default. Unset entirely (with the other EMAIL_* vars) to disable email sign-in: the signin form hides the option. |
EMAIL_SERVER_PORT |
Yes (for magic-link) | 1025 |
SMTP port. When EMAIL_SERVER_HOST is set, boot fails fast in production if this is missing or not a valid port. |
EMAIL_SERVER_USER |
No | - | SMTP username (omit for MailDev). |
EMAIL_SERVER_PASSWORD |
No | - | SMTP password. |
EMAIL_FROM |
Yes (for magic-link) | - | "From" address for magic-link sends, e.g. noreply@yourapp.com. When EMAIL_SERVER_HOST is set, boot fails fast in production if this is missing. |
A partial email config (any EMAIL_* var set while EMAIL_SERVER_HOST is unset) also fails boot in production: it almost always means a forgotten host, and the only symptom otherwise is email sign-in silently missing from the signin page.
| AUTH_GITHUB_ID | No | - | GitHub OAuth client ID. |
| AUTH_GITHUB_SECRET | No | - | GitHub OAuth client secret. |
| AUTH_GOOGLE_ID | No | - | Google OAuth client ID. |
| AUTH_GOOGLE_SECRET | No | - | Google OAuth client secret. |
Frontend never sees DB credentials. Only the backend talks to PostgreSQL.
Notes
AUTH_SECRET
The single most important shared value. Frontend and backend both need it. Generate one and paste it into both .env files:
openssl rand -base64 32
NextAuth uses it for session cookie signing on the frontend. The backend currently doesn't use it, but rotating both at once is good hygiene.
INTERNAL_API_TOKEN
The frontend's NextAuth adapter sends this as the X-Internal-Token header on every adapter call. The backend InternalApiTokenGuard validates it with a timing-safe compare. Must be identical on both sides: a mismatch surfaces as opaque NextAuth OAuthCallbackError (sign-in fails with no obvious reason).
Rotation: change on the backend, change on the frontend, restart both. There's a short gap where in-flight calls 401; sign-ins during the gap will fail and need retry.
Database
Only the backend talks to the database: Knex + DATABASE_URL. The frontend never holds DB credentials; it reaches the backend via NEXT_PUBLIC_API_URL (public REST) and INTERNAL_API_URL + INTERNAL_API_TOKEN (server-only NextAuth adapter calls).
OAuth Providers
GitHub and Google are included as examples. Adjust frontend/auth.config.ts to add or remove providers. Each provider needs its own ID and secret from the provider's developer console.
To set up GitHub OAuth:
- Go to GitHub Settings → Developer settings → OAuth Apps.
- Create a new app with callback URL
http://localhost:3000/api/auth/callback/github(and a production callback for your real host). - Copy the Client ID and Client Secret into
AUTH_GITHUB_ID/AUTH_GITHUB_SECRET.
To set up Google OAuth:
- Go to Google Cloud Console → APIs & Services → Credentials.
- Create an OAuth 2.0 Client ID with redirect URI
http://localhost:3000/api/auth/callback/google(plus production). - Copy the Client ID and Client Secret into
AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET.
SMTP
Two halves, one transport:
- The frontend sends magic-link emails via NextAuth's Nodemailer provider:
EMAIL_SERVER_*andEMAIL_FROM. - The backend sends bulk/transactional emails via its queue + worker:
SMTP_*andSMTP_FROM.
Both halves can point at the same SMTP server in production (any provider works). They're independent because they live in different processes.
For local dev, run MailDev (yarn dev:email from the backend): SMTP on :1025, web UI on http://localhost:1080. The default EMAIL_SERVER_* and SMTP_* values point straight at it.
What's public
Only variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Currently that's NEXT_PUBLIC_API_URL and NEXT_PUBLIC_SITE_URL. Everything else stays server-side, including the bearer session token (it's in an httpOnly cookie, never serialized into the page).
Minimal Setup
If you just want to get running quickly with OAuth only (no magic links, no production hardening):
backend/.env
PORT=4000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/crabstack
NODE_ENV=development
ALLOWED_ORIGINS=http://localhost:3000
AUTH_SECRET=<openssl rand -base64 32>
ADMIN_MCP_API_KEY=<openssl rand -base64 32>
INTERNAL_API_TOKEN=<openssl rand -base64 32>
SMTP_FROM=noreply@crabstack.local
frontend/.env
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_SITE_URL=http://localhost:3000
AUTH_URL=http://localhost:3000
INTERNAL_API_URL=http://localhost:4000
INTERNAL_API_TOKEN=<same as backend>
AUTH_SECRET=<same as backend>
AUTH_GITHUB_ID=<from github>
AUTH_GITHUB_SECRET=<from github>
EMAIL_FROM=noreply@crabstack.local