Environment variables
This page is the complete list. For the must-set subset see
Required environment variables;
for integration keys see
Optional integration keys.
The annotated source of truth is
.env.example.
Ownership model
Section titled “Ownership model”Two layers:
- App-level (
apps/*/src/config/env.ts) — vars the app itself cares about (bind port, DB URL, frontend origin, session signing). Each app validates with zod at boot. - Package-level (
packages/infra/<pkg>/src/config.ts) — vars that belong to a package (@scani/securityownsENCRYPTION_KEY,@scani/storageownsS3_*,@scani/emailownsFASTMAIL_API_TOKEN/SMTP_URL, …).
Apps that depend on a package do not redeclare the package’s
vars. The package’s loadXConfig() validates and caches; the app
just sets the env var.
See Engineering conventions for the ownership rule.
Core (required for any deployment)
Section titled “Core (required for any deployment)”Tier wiring
Section titled “Tier wiring”Admin dashboard
Section titled “Admin dashboard”The passkey-gated infra console (apps/frontend/admin, Next.js). None
of these are needed to run Scani — the admin app is an operator tool and
a self-host deployment can skip it entirely.
Storage
Section titled “Storage”Logging
Section titled “Logging”Provider keys (read by the api and worker)
Section titled “Provider keys (read by the api and worker)”These are required on every tier, including Tier 2/3. All three
backend services boot buildProviderRegistry({ mode: 'direct' }) and
call these upstreams themselves; mode is a string literal that no
environment variable can change. Pointing SCANI_CLOUD_URL at a
hosted data-provider does not move them.
Missing keys degrade silently rather than failing at boot. Check what
your stack resolved with the provider credentials: boot line —
How to tell what’s
enabled.
Observability
Section titled “Observability”API port shape across deployment layers
Section titled “API port shape across deployment layers”The number 3001 shows up in three places that mean different things;
trying to “fix” any one of them in isolation tends to break the other
two:
VITE_API_URL follows the same split: http://localhost:3001 in
host-dev, http://localhost:3011 in dev compose (frontend container’s
own env), /api baked into the prod frontend-app image so nginx
handles routing.
Health-check endpoints
Section titled “Health-check endpoints”All exposed by apps/backend/api (and surfaced via nginx as
/api/* in prod compose):
The data-provider exposes /health (process liveness) on its bind
port. The prod frontend-app image exposes /healthz (nginx alive),
not to be confused with /api/health/* (which goes through to the
api).
Local development stack
Section titled “Local development stack”Set by scripts/dev-stack.ts and by the e2e runner (apps/e2e/scripts/run.ts)
from the checkout’s own path, so two checkouts can hold a stack at once
(SC-491, SC-493). Nobody sets them by hand — but a value already in the
environment wins, because a person driving several stacks has a reason.
Demo mode
Section titled “Demo mode”Read by the api and the worker of a demo deployment only
(demo.scani.xyz). On any other deployment both are unset and nothing
in this section applies.
A demo flag a production deployment could set is a data-exposure bug
rather than a feature toggle, so three independent things have to hold
before an anonymous visitor is served anything, and the flag is the
weakest of them. The flag must be exactly 1. The database must
hold the demo persona and nothing else — the api reads every email in
users at boot and exits otherwise, which is what makes the flag
impossible to set against a real deployment rather than merely
inadvisable. An empty database is refused too: nothing about an empty
database proves it is a demo, and an unmigrated database, an empty
replica and a typo’d DATABASE_URL all look exactly like one. The
identity the api synthesizes is only ever the demo persona — no
header, cookie or input selects another — so a process that got past the
first two resolves to a user that does not exist and every user-scoped
read returns nothing.
The worker arms one schedule, demo-reset, and removes whatever a
previous boot armed. Every other schedule is wrong for a demo and two
are destructive: the hourly pricing job would overwrite the seeded price
series and take the dataset’s determinism with it, and the nightly
rollup would recompute portfolio values over transactions nobody made.
Testing-only
Section titled “Testing-only”These vars are read only by the e2e test runner under apps/e2e/
and the related fixtures / scripts. They have no effect on a
production deployment — operators can ignore this section.
Validation pattern
Section titled “Validation pattern”Every loader uses zod and the helpers from @scani/config:
isProduction—process.env.NODE_ENV === 'production'at load.urlSchema/httpsUrlInProduction— URL with prod-only https requirement.requiredInProd(schema, name)— returns the schema unchanged in prod,.optional()everywhere else. Lets dev/test boot without the var; prod refuses to start without it.
On a parse failure, the loader throws with a message listing every failing variable:
@scani/security env misconfigured: - ENCRYPTION_KEY: ENCRYPTION_KEY required in productionAdding a new env var
Section titled “Adding a new env var”- Where does it belong? If it’s about a package’s behaviour
(a new third-party API key, a logging knob), it goes in that
package’s
src/config.ts. If it’s about an app (a new bind address), it goes inapps/*/src/config/env.ts. - Add it to root
.env.examplewith an annotation. - Add it to the relevant app’s
.env.example(soscripts/sync-env.tspropagates it to the per-app.env). - Validate it in the right loader.
- Document it on this page and on Required env or Optional keys.
See also
Section titled “See also”- Required environment variables
- Optional integration keys
- Engineering conventions — env-var ownership rule.
.env.examplein the repo root for the canonical comments.