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. 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. 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, STRIPE_WEBHOOK_SECRET becomes 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 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. Must be a positive integer: boot fails on anything else, because zero or negative stops the queue silently and Infinity retries a poisoned row forever. |
TEST_DATABASE_URL |
No | - | Points the integration specs at a migrated Postgres. Unset, they skip, so a bare yarn 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_WINDOW_MS |
No | 300000 (5 min) |
Sliding window (ms) over which MAGIC_LINK_MAX_ATTEMPTS is 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. 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, 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. |
Run yarn 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 yarn migrate uses the same rules there.
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. Next bakes this into the built bundle at next build time, so a missing value can only be caught by the build pipeline or, once running, by auth.ts throwing on the first request that touches auth - not by a boot-time exit, since by next start the wrong value already shipped. |
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. Used for sitemap, robots.txt, OG meta. Same build-time caveat as NEXT_PUBLIC_API_URL above: catch it in the build pipeline, not at boot. |
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. Read at real runtime, not baked in, so instrumentation.ts checks it at server startup and exits the process if unset when NODE_ENV=production - a genuine boot failure. |
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. |
SERVER_FETCH_TIMEOUT_MS |
No | 10000 |
Deadline in milliseconds on every server-side request to the backend: RSC reads, serverApi(), the NextAuth adapter, and the proxy's admin probe. Node's fetch has no default timeout, so without one a backend that accepts a connection and then never answers (stuck process, exhausted pool, half-open connection through a load balancer) hangs the awaiting render with no upper bound, and those sockets accumulate against the Next.js server until it stops accepting. Reads degrade the way they already do on a refused connection; the sign-out delete and the admin probe still fail closed. Browser requests are unaffected. 0 disables the deadline and restores the unbounded wait. Anything else outside 0-2147483647 throws with a message naming this variable rather than falling back. Read the two notes below before tuning it. |
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. Read at real runtime like AUTH_URL above, so instrumentation.ts also exits the process at startup if this is unset when NODE_ENV=production. |
EMAIL_SERVER_HOST |
Yes (for magic-link) | - | SMTP host for magic-link sends. Unset entirely (with the other EMAIL_* vars) to disable email sign-in: the signin form hides the option. frontend/.env.example supplies localhost for MailDev development. |
EMAIL_SERVER_PORT |
Yes (for magic-link) | - | SMTP port. When EMAIL_SERVER_HOST is set, boot fails fast in production if this is missing or not a valid port. frontend/.env.example supplies 1025 for MailDev development. |
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. [email protected]. When EMAIL_SERVER_HOST is set, boot fails fast in production if this is missing. |
AUTH_GITHUB_ID / AUTH_GITHUB_SECRET |
No | - | GitHub OAuth credentials. Both unset hides the button. |
AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET |
No | - | Google OAuth credentials. Both unset hides the button. |
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 other symptom is email sign-in silently missing from the signin page.
Why the budget is applied twice
SERVER_FETCH_TIMEOUT_MS is enforced in two places, and both are load-bearing.
lib/fetch-timeout.ts puts an AbortSignal on each request the openapi-fetch
clients make. That bounds the whole call, is what unblocks a render waiting
on it, and composes with a caller's own abort - but it does not always reach
the socket. Next strips the signal when it revalidates a
next: { revalidate } entry: doOriginalFetch in
next/dist/server/lib/patch-fetch.js rebuilds the outgoing request from a
field list that omits signal once the entry is stale. So the background
refresh behind the three data-cached reads (getBlogPosts, getBlogPost,
getBillingEnabled) escapes that layer entirely.
instrumentation.ts therefore installs the same budget as an undici
Agent (headersTimeout and bodyTimeout) at server startup, below anything
Next can strip. Measured against a backend that accepts and never answers, with
/blog's cache entry stale: 20 requests held 20 sockets open in the
Next.js process without it, and 0 with it.
That dispatcher is scoped to the backend origins - whatever
API_URL_SERVER/NEXT_PUBLIC_API_URL and INTERNAL_API_URL resolve to - and
every other host keeps undici's defaults. A dispatcher is process-wide by
nature, and this budget is for one dependency we control; applied to
everything it would also govern the OAuth token and userinfo calls NextAuth
makes to Google and GitHub, turning a slow identity provider into a failed
sign-in, with 0 as the only escape and the revalidation bound lost along with
it. If you point the app at a second backend host, add it there.
Raising the budget raises both layers. Setting it to 0 disables both.
The trade-off a deadline buys
A timeout converts "waited forever" into "gave up", and gave up is not the
same as did not happen. A write that the backend commits at 10.4s with a 10s
budget is reported to the user as a failure it will not undo:
deleteAccountAction says the account was not deleted while the deletion
proceeds, checkout says it could not start after Stripe minted the session, and
a magic link consumed by useVerificationToken is burned even though sign-in
reported an error. None of these is reachable through a refused connection,
which is why they arrive with the deadline. Size the budget above your
backend's real p99 - the point is to catch a stall, not a slow query - and
raise it if writes are being reported as network errors.
The frontend never holds database credentials. Only the backend talks to PostgreSQL.
Notes
The two shared secrets
AUTH_SECRET and INTERNAL_API_TOKEN must be identical on both sides. Generate each with openssl rand -base64 32.
A mismatched INTERNAL_API_TOKEN is the one worth recognising on sight: every NextAuth adapter call carries it as X-Internal-Token, so a mismatch surfaces as an opaque OAuthCallbackError and sign-in fails with no obvious reason. To rotate either one: set the new value on the backend, then the frontend, then restart both. There is no overlap window, so calls in the gap 401 and sign-ins during it need a retry.
AUTH_SECRET signs NextAuth's session cookie on the frontend. The backend does not use it today; rotating both together is still the right habit.
OAuth providers
GitHub and Google ship as examples, each registered only when its credentials are present. Add or remove providers in frontend/auth.config.ts. Callback URLs and the step-by-step app registration are in the README.
SMTP
The frontend's EMAIL_SERVER_* + EMAIL_FROM and the backend's SMTP_* + SMTP_FROM are independent because they live in different processes; both can point at one provider. Defaults target MailDev. See Email.
What's public
Only NEXT_PUBLIC_-prefixed variables reach the browser: today that is NEXT_PUBLIC_API_URL and NEXT_PUBLIC_SITE_URL. Everything else stays server-side, including the bearer session token, which lives in an httpOnly cookie and is 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>
[email protected]
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 protected]