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, with one exception: a variable the hosting platform injects rather than you configuring (PORT, VERCEL, VERCEL_ENV, VERCEL_GIT_COMMIT_*) is read at the point of use and belongs in neither the schema nor .env.example. 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/load-dotenv.ts. The server slices (api-hono, auth, db) import that loader; web-next does not, because client components import it and dotenv's browserified node graph would follow them onto every page, so a build-time consumer that reaches env only through web-next (next.config.ts, generate-env.ts) imports @packages/env/load-dotenv itself. 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.
Every variable
.env.example holds the ones you are expected to set. The Default column reads required where there is none and the app will not boot without a value. HONO_PORT, HONO_WEB_URL and INTERNAL_API_URL are valid but left out of it on purpose: each has a working default or is wired for you.
| Variable | Slice | Default | What it does |
|---|---|---|---|
AGENT_SIGNIN_ENABLED | api-hono | false | Mounts the local agent sign-in, and only while NODE_ENV is local |
BETTER_AUTH_SECRET | auth | required | Signs sessions; init generates one |
GITHUB_CLIENT_ID / _SECRET | auth | optional | Turns on GitHub sign-in when both are set |
GOOGLE_CLIENT_ID / _SECRET | auth | optional | Turns on Google sign-in when both are set |
HONO_APP_URL | api-hono, auth | required | The api's public origin; the cookie scope and Better Auth's baseURL derive from it |
HONO_PORT | api-hono | 4000 | The port the api binds when the host injects no PORT |
HONO_RATE_LIMIT | api-hono | 60 | Requests per window; signed-in users get twice this |
HONO_RATE_LIMIT_WINDOW_MS | api-hono | 60000 | The window, in milliseconds |
HONO_TRUSTED_ORIGINS | api-hono, auth | required | Comma-separated origins CORS and Better Auth accept |
HONO_WEB_URL | auth | inferred | Names the web origin on a public hosting suffix when it cannot be inferred |
INTERNAL_API_URL | web-next (server) | optional | Where the web server reaches the api; Docker sets it to http://api:4000 |
NEXT_PUBLIC_API_URL | web-next (client) | required | The api origin the browser calls |
NEXT_PUBLIC_APP_URL | web-next (client) | required | The web origin: canonical URLs, the sitemap, OG images |
NEXT_PUBLIC_POSTHOG_HOST | web-next (client) | EU cloud | PostHog region or self-hosted URL |
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN | web-next (client) | optional | Turns PostHog on |
NEXT_PUBLIC_USERJOT_URL | web-next (client) | optional | Shows the feedback link |
NODE_ENV | every slice | required | The stage; the web also exposes it to the client as NEXT_PUBLIC_NODE_ENV, which you never set |
POSTGRES_URL | db | required | The database; init fills it when it provisions one |
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. The API bundle reads it at runtime rather than at build (bun build --env disable), so the process or container env decides the stage, never the shell that ran the build.
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.