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, 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 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.

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.

VariableSliceDefaultWhat it does
AGENT_SIGNIN_ENABLEDapi-honofalseMounts the local agent sign-in, and only while NODE_ENV is local
BETTER_AUTH_SECRETauthrequiredSigns sessions; init generates one
GITHUB_CLIENT_ID / _SECRETauthoptionalTurns on GitHub sign-in when both are set
GOOGLE_CLIENT_ID / _SECRETauthoptionalTurns on Google sign-in when both are set
HONO_APP_URLapi-hono, authrequiredThe api's public origin; the cookie scope and Better Auth's baseURL derive from it
HONO_PORTapi-hono4000The port the api binds when the host injects no PORT
HONO_RATE_LIMITapi-hono60Requests per window; signed-in users get twice this
HONO_RATE_LIMIT_WINDOW_MSapi-hono60000The window, in milliseconds
HONO_TRUSTED_ORIGINSapi-hono, authrequiredComma-separated origins CORS and Better Auth accept
HONO_WEB_URLauthinferredNames the web origin on a public hosting suffix when it cannot be inferred
INTERNAL_API_URLweb-next (server)optionalWhere the web server reaches the api; Docker sets it to http://api:4000
NEXT_PUBLIC_API_URLweb-next (client)requiredThe api origin the browser calls
NEXT_PUBLIC_APP_URLweb-next (client)requiredThe web origin: canonical URLs, the sitemap, OG images
NEXT_PUBLIC_POSTHOG_HOSTweb-next (client)EU cloudPostHog region or self-hosted URL
NEXT_PUBLIC_POSTHOG_PROJECT_TOKENweb-next (client)optionalTurns PostHog on
NEXT_PUBLIC_USERJOT_URLweb-next (client)optionalShows the feedback link
NODE_ENVevery slicerequiredThe stage; the web also exposes it to the client as NEXT_PUBLIC_NODE_ENV, which you never set
POSTGRES_URLdbrequiredThe 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

  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