ZeroStarter

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 4000

At 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 optional AGENT_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 optional HONO_WEB_URL (names the web origin on a public hosting suffix when HONO_TRUSTED_ORIGINS holds more than one; otherwise inferred)
  • @packages/env/db: POSTGRES_URL
  • @packages/env/web-next: the server-only INTERNAL_API_URL plus the client NEXT_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

  1. Add it to the root .env.
  2. Declare it in the right slice under packages/env/src/*.ts, in the server or client schema with a Zod type, and map it in that file's runtimeEnv object.
  3. Add it to globalEnv in turbo.json so 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=true covers every variable, server and client. This is the CI/Docker escape hatch, used before any real secrets exist.
  • SKIP_ENV_VALIDATION_SERVER=true covers only the server-only variables (POSTGRES_URL, BETTER_AUTH_SECRET, HONO_APP_URL, HONO_TRUSTED_ORIGINS); the public NEXT_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