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. Baked into the bundle at next build, so set it as a build arg. |
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. Baked in at next build like NEXT_PUBLIC_API_URL. |
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. |
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. 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. |
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. 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]. |
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. |
CLIENT_IP_HEADER |
No | - | Same value as the backend's CLIENT_IP_HEADER. When the named header holds an IP, the magic-link per-IP cap keys on it instead of the last X-Forwarded-For element (see Auth Flow). Setting it on a platform that does not overwrite the header lets every client pick its own address. |
Which of these stop boot: What refuses boot.
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.