Skip to content

Startup validation

A misconfigured Holotable used to boot green and fail in front of a user: an unset AI_MODEL surfaced on the first generate request, a missing DATABASE_URL on the first database call, a weak SESSION_SECRET never at all outside production. Since #39 the server validates the whole environment at startup and reports every problem at once.

Configuration is invalid (2 errors, 1 warning); refusing to start.
error SESSION_SECRET: is the placeholder from .env.example, which is public. Generate a unique value with `openssl rand -base64 32`.
error AI_MODEL: is not set; every generate request would fail. Set the model id for your AI_PROVIDER (see .env.example).
warning SOURCE_SECRET_REFS: does not grant secret_ref "TS_METRICS" to workspace "ops"; its sources there will fail on Test and on every query until it does.

src/instrumentation.ts is the Next 16 startup hook; its register runs once per server instance and must complete before the first request is served. It calls runStartupChecks (src/lib/startup.ts), prints the report, and exits the process with status 1 when there is an error. The hook is skipped during next build, which invokes it while prerendering with NODE_ENV=production and no deployment environment; a production build still succeeds with no .env at all.

npm run config:check runs the same validation without starting the server and exits 1 on an error. It reads the same .env* files Next does. Use it as a pre-deploy gate:

Terminal window
NODE_ENV=production npm run config:check

CI runs it against .env.example in development mode, so the example file can never stop a fresh checkout from starting.

An error refuses to boot. A warning is printed and ignored.

Values that are wrong in every environment are always errors: a URL that does not parse, an unknown AI_PROVIDER, MIN_REFRESH_INTERVAL_MS above DEFAULT_REFRESH_INTERVAL_MS, a default time range that ends before it starts, an OIDC_SCOPE without openid.

Values that are merely missing are errors in production and warnings in development. NODE_ENV=production selects production. An .env copied from .env.example therefore starts the dev server with a few warnings, while a production deployment cannot boot without:

Variable Rule
DATABASE_URL Set, and a postgresql:// URL.
SESSION_SECRET Set, at least 32 characters, not the .env.example placeholder, and not a run of a few repeated characters.
AI_MODEL Set.
OPENAI_API_KEY Set when AI_PROVIDER is openai-compatible (the default). OPENAI_BASE_URL is optional and defaults to OpenAI, but must be an http(s) URL when set.
AI_GATEWAY_API_KEY Set when AI_PROVIDER is gateway.
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_JWKS_URL All set: Keycloak is the only way to sign in, and the client is confidential. OIDC_REDIRECT_URI is derived from the request origin when unset.

Every numeric knob (MAX_QUERY_ROWS, MAX_RESULT_BYTES, QUERY_TIMEOUT_SECONDS, …) must be a positive integer when set, LLM_RATE_PER_MINUTE and LLM_DAILY_TOKEN_BUDGET a non-negative integer (0 disables that limit, which in production is a warning on every boot), DEFAULT_TIME_FROM/DEFAULT_TIME_TO must be time expressions the server can resolve, and CSP_REPORT_ONLY must be true or false. Setting it to true in production is a warning on every boot: the Content-Security-Policy is then logged, not enforced. The full list of variables is in Configuration.

SOURCE_SECRET_REFS is part of the environment check: unset is an error in production and a warning in development, and a malformed value is an error in both. SOURCE_SECRETS_DIR, when set, must be an absolute path, and a warning is printed if it is not a readable directory.

After the environment passes, the server reads the secret_ref and workspace of every live source from the config store and checks two things: that SOURCE_SECRET_REFS grants the ref to that workspace, and that <SECRET_REF>_USERNAME and <SECRET_REF>_PASSWORD resolve, as files in SOURCE_SECRETS_DIR or from the environment. Either failure is a warning, never an error: sources are created at runtime, and a source whose credentials arrive with the next deploy should not keep the whole server down. The same failure still surfaces on Test and on every query, see Source secret references.

The query waits at most five seconds. If the database is not reachable yet, the check degrades to a single warning naming the connection error, and the server starts; every later database call reports its own failure as before.

validateConfig in src/lib/config.ts is pure: it takes an environment map and returns ConfigProblems, so a rule is a few lines and a test in test/config.test.ts. Shape checks (is this a URL, an enum, a positive integer) belong in the Zod EnvSchema; presence and cross-variable rules belong in the function body, using missing() for values whose severity depends on the environment and error() for values that are wrong everywhere. Every message names the variable and says what to do.