Environment Variables
One validated .env, read per-consumer so client code can never touch server secrets.
There is one .env at the repo root, and no package reads process.env directly. Each consumer imports a validated, typed slice of the environment from @packages/env/<name>, so there is one place to set a variable, every variable is checked at boot instead of surfacing as undefined mid-request, and server secrets are structurally unable to reach the browser.
One file, read per consumer
@packages/env loads the root .env once (via dotenv, in packages/env/src/lib/utils.ts). Each app then imports only the slice it declared:
// in api/hono, never process.env
import { env } from "@packages/env/api-hono"
env.HONO_PORT // number, validated, defaults to 4000At runtime the api binds process.env.PORT when the host injects one (portless assigns it in dev; platforms like Cloud Run or Railway set it in production), falling back to HONO_PORT otherwise.
The four slices, each a Zod schema that validates only what that consumer needs:
@packages/env/api-hono:HONO_APP_URL,HONO_PORT, the rate-limit knobs,HONO_TRUSTED_ORIGINS, and the optionalAGENT_SIGNIN_ENABLED(gates the local agent sign-in)@packages/env/auth:BETTER_AUTH_SECRET,HONO_APP_URL,HONO_TRUSTED_ORIGINS, the optional OAuth pairs, and the optionalHONO_WEB_URL(names the web origin on a public hosting suffix whenHONO_TRUSTED_ORIGINSholds more than one; otherwise inferred)@packages/env/db:POSTGRES_URL@packages/env/web-next: the server-onlyINTERNAL_API_URLplus the clientNEXT_PUBLIC_*vars
A variable that is missing or malformed at startup fails validation with a message naming it, so a bad deploy dies at boot rather than halfway through a request.
Server and client
web-next is the only slice split into server and client, and the split is the security boundary: only variables prefixed NEXT_PUBLIC_ are in the client schema, and only those (plus NEXT_PUBLIC_IS_PRIVATE, a boolean next.config derives at build and injects, not a user-set value) are bundled into browser code. INTERNAL_API_URL stays server-side; POSTGRES_URL and BETTER_AUTH_SECRET are not in this slice at all.
Why secrets can't leak
The prefix is the rule the bundler enforces. A server secret has no NEXT_PUBLIC_ name, so it is never part of the client schema, so it can never be inlined into a page. Exposing a value to the browser means renaming it, a deliberate act, not an accident.
Stages
NODE_ENV is a five-stage enum, centralized in @packages/env and validated everywhere: local → development → test → staging → production. When it is set to a known stage, a stage-specific .env.<stage> file (e.g. .env.production) is layered on top of the base .env with override: true.
To branch on the stage, @packages/env also exports checkers (isLocal, isProduction, and one per stage) so code reads isProduction(env.NEXT_PUBLIC_NODE_ENV) instead of comparing raw strings.
Adding a variable
- Add it to the root
.env. - Declare it in the right slice under
packages/env/src/*.ts, in theserverorclientschema with a Zod type, and map it in that file'sruntimeEnvobject. - Add it to
globalEnvinturbo.jsonso Turbo's cache invalidates when it changes.
runtimeEnv is mandatory
A variable declared in the schema but missing from the runtimeEnv map is silently undefined at runtime. Always add both.
Skipping validation
Two flags let a build pass without real secrets: missing required variables fall back to shape-valid dummies, except BETTER_AUTH_SECRET, which is made optional and stays undefined rather than a predictable dummy. Validation still runs otherwise, so a present-but-invalid value always fails:
SKIP_ENV_VALIDATION=truecovers every variable, server and client. This is the CI/Docker escape hatch, used before any real secrets exist.SKIP_ENV_VALIDATION_SERVER=truecovers only the server-only variables (POSTGRES_URL,BETTER_AUTH_SECRET,HONO_APP_URL,HONO_TRUSTED_ORIGINS); the publicNEXT_PUBLIC_*client vars stay required and validated. The web build sets this: it compiles the server packages for their types without their secrets, but must still guarantee the client vars it inlines and ships.
Never set either for a running app: the server would then start against dummy URLs and an undefined BETTER_AUTH_SECRET instead of real values.
Next
- Database: where
POSTGRES_URLis consumed. - Auth & Organizations: the OAuth pairs and
BETTER_AUTH_SECRET. - Setup: creating your first
.env.