ZeroStarter

Environment Variables

Configure type-safe environment variables for your application.

Overview

This project uses a centralized, type-safe environment variable system. There's one .env file to rule them all at the project root, but each package only gets what it asks for through validation.

Environment Stages

The project supports five environment stages in order: local → development → test → staging → production

  • local: Your local development machine.
  • development: Development server/environment.
  • test: Testing environment.
  • staging: Pre-production environment. Production-like settings.
  • production: Production environment.

The NODE_ENV environment variable is centralized in @packages/env and validated across all packages.

How It Works

Single Source of Truth

All environment variables are stored in a single .env file at the project root. The @packages/env package automatically loads this file using dotenv configured in packages/env/src/lib/utils.ts. If a NODE_ENV value is set and matches a known stage, a stage-specific .env.<NODE_ENV> file (e.g. .env.production) is then layered on top with override: true.

Package-Specific Validation

Each package defines only the environment variables it needs:

  • api-hono: HONO_APP_URL, HONO_PORT, HONO_RATE_LIMIT, HONO_RATE_LIMIT_WINDOW_MS, HONO_TRUSTED_ORIGINS
  • auth: BETTER_AUTH_SECRET, HONO_APP_URL, HONO_TRUSTED_ORIGINS, plus the optional OAuth pairs GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET and GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET (a provider registers only when both halves of its pair are set)
  • db: POSTGRES_URL. When INTERNAL_API_URL is set, the db package rewrites localhost in POSTGRES_URL to host.docker.internal so containers can reach a database on the host.
  • web-next:
    • Server: INTERNAL_API_URL (server-side only, for internal service-to-service calls)
    • Client (required): NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_API_URL, and NEXT_PUBLIC_NODE_ENV. NEXT_PUBLIC_NODE_ENV is not a value you set: it is derived at runtime from process.env.NODE_ENV, mirroring the stage enum to the browser.
    • Client (optional): NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_USERJOT_URL (all exposed to browser)

Server variables cannot be used on the client. Only variables prefixed with NEXT_PUBLIC_ are exposed to the browser. This prevents sensitive data like secrets and API keys from being exposed in client-side code.

Implementation

Each package uses @t3-oss/env-core with Zod schemas for type-safe validation:

// packages/env/src/api-hono.ts

import { createEnv } from "@t3-oss/env-core"
import { z } from "zod"
import "@/lib/utils" // Loads root .env
import { NODE_ENV } from "@/lib/constants"

export const env = createEnv({
  server: {
    NODE_ENV, // Centralized enum: local, development, test, staging, production
    HONO_APP_URL: z.url(),
    HONO_PORT: z.coerce.number().default(4000),
    HONO_RATE_LIMIT: z.coerce.number().default(60),
    HONO_RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000),
    HONO_TRUSTED_ORIGINS: z
      .string()
      .transform((s) => s.split(",").map((v) => v.trim().replace(/\/$/, "")))
      .pipe(z.array(z.url())),
  },
  runtimeEnv: {
    NODE_ENV: process.env.NODE_ENV,
    HONO_APP_URL: process.env.HONO_APP_URL,
    HONO_PORT: process.env.HONO_PORT,
    HONO_RATE_LIMIT: process.env.HONO_RATE_LIMIT,
    HONO_RATE_LIMIT_WINDOW_MS: process.env.HONO_RATE_LIMIT_WINDOW_MS,
    HONO_TRUSTED_ORIGINS: process.env.HONO_TRUSTED_ORIGINS,
  },
  emptyStringAsUndefined: true,
  skipValidation: process.env.SKIP_ENV_VALIDATION === "true",
})

Setting SKIP_ENV_VALIDATION=true bypasses validation entirely, which is how CI and Docker builds run before real secrets are present. In web-next, when SKIP_ENV_VALIDATION is "true" and NEXT_PUBLIC_APP_URL or NEXT_PUBLIC_API_URL is unset, each is substituted with https://polyfill.url so the build can proceed. emptyStringAsUndefined treats empty strings as missing so optional vars fall back to their defaults.

Usage in Packages

Import the specific env for your package:

// api/hono/src/index.ts
import { env } from "@packages/env/api-hono"
// web/next/src/lib/config.ts
import { env } from "@packages/env/web-next"

Shared Helpers

The package root (@packages/env) also re-exports a few stage-aware helpers, separate from the per-package validated env objects:

import {
  isLocal,
  isDevelopment,
  isTest,
  isStaging,
  isProduction,
  getSafeEnv,
  VERSION,
  GIT_SHA,
  BUILD_VERSION,
  getBuildVersion,
} from "@packages/env"
  • Stage checkers (isLocal, isDevelopment, isTest, isStaging, isProduction): each takes a NODE_ENV string and returns whether it matches that stage, e.g. isProduction(env.NEXT_PUBLIC_NODE_ENV).
  • getSafeEnv(env, appName?): returns a copy of the given env object with sensitive keys (anything whose name contains key, secret, token, password, postgres_url, db_url, or database_url) replaced by a redacted placeholder. It only logs the result when NODE_ENV is local, so it is safe to call when wiring up diagnostics.
  • Build version helpers (VERSION, GIT_SHA, BUILD_VERSION, getBuildVersion()): the version and commit metadata injected at build time. getBuildVersion() prefers the platform's deploy-time SHA (VERCEL_GIT_COMMIT_SHA) when present, falling back to the baked BUILD_VERSION.

Creating and Managing

  1. Create .env file: Copy .env.example to .env at the project root
  2. Add variables: Fill in all required variables (see .env.example for reference)
  3. Add new variables:
    • Add to .env file
    • Define in the appropriate package's env file (packages/env/src/*.ts)
    • Add to server or client schema with Zod validation
    • Map it in the same file's runtimeEnv object (e.g. MY_VAR: process.env.MY_VAR).
    • Add to turbo.json: Include the variable in the globalEnv array so Turbo's cache invalidates correctly when it changes
  4. Validation: Each package validates only its declared variables at runtime

The runtimeEnv mapping is mandatory. A variable declared in the schema but missing from runtimeEnv is silently undefined at runtime.

globalEnv exception: NEXT_PUBLIC_NODE_ENV is not a real variable you set and is not added to turbo.json's globalEnv. Its runtimeEnv mapping derives it from process.env.NODE_ENV (which is already in globalEnv), so listing it separately would be redundant.

Features

  • Type-safe with TypeScript inference
  • Runtime validation with clear error messages
  • Selective access per package
  • Server/client separation (only NEXT_PUBLIC_* exposed to browser)
  • Centralized NODE_ENV enum supporting: local, development, test, staging, production
  • Environment-specific logging (only logs in local environment)

Required Variables

See the root .env.example for all required environment variables. Each package validates only its subset, so you can have all variables in one place without exposing unnecessary ones to each package.