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”Storage
Section titled “Storage”Human check (Cloudflare Turnstile)
Section titled “Human check (Cloudflare Turnstile)”Both optional; unset, sign-in and the contact form work with no check. Set the site key and deploy the frontends before setting the secret, or every sign-in is refused.
Logging
Section titled “Logging”Provider keys (Tier 1 API/worker and Scani-managed processing service)
Section titled “Provider keys (Tier 1 API/worker and Scani-managed processing service)”Tier 1 API and worker use these credentials for enabled integrations. In Tier 2,
set SCANI_DEPLOYMENT_TIER=2 on both services: platform calls use Scani Cloud
and none of the keys below are needed locally. Scani manages the provider
accounts on the processing service. Personal exchange and brokerage credentials
remain local and are separate from these platform keys.
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):
/readyz, /health/db, /health/ws and /health/deep keep their status
codes for every caller, but return their detail — error text, pool stats,
providerCredentials, costControls, index definitions — only to a request
carrying Authorization: Bearer <DIAGNOSTICS_TOKEN>. Anyone else gets
status, timestamp and each check’s ok, which is everything a probe or a
deploy smoke reads. Unset, nobody gets the detail. The data-provider’s
/health/deep and /health/r2 follow the same rule with its own
DIAGNOSTICS_TOKEN; give the api the same value so its R2 check can read the
data-provider’s detail.
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.