Skip to content

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.

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/security owns ENCRYPTION_KEY, @scani/storage owns S3_*, @scani/email owns FASTMAIL_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.

VariableOwnerWhat it does
NODE_ENVappproduction for any real deployment.
DATABASE_URLappPostgres 16+ connection string.
POSTGRES_POOL_MAXappPer-app pool size. Set to 5 behind a connection pooler.
REDIS_URLappRedis 7+ connection string.
PORTapp (api / data-provider)HTTP bind port.
HOSTappHTTP bind host.
ALERT_STALE_SYNC_HOURSworkerHow long an integration must have gone without syncing before its OWNER is emailed by alert-sweep (SC-459). Default 24. Deliberately far above STALE_SYNC_THRESHOLD_HOURS, which is the same measurement aimed at Sentry: 3h is two missed hourly cycles, the right moment to page us and the wrong moment to mail a user about a blip that clears itself.
FRONTEND_URLapp (api), workerBrowser-facing SPA URL. CORS + cookie scope on the api; where the weekly digest’s “Open Scani” button goes, and where the integration alert’s “Reconnect” button goes, on the worker (SC-460, SC-459). Optional on the worker — absent, both jobs log a refusal and send nothing.
CLOUD_FRONTEND_URLapp (api)Origin of the Cloud console (https://cloud.scani.xyz). Added to the api’s CORS allow-list and Better-Auth trusted origins so the console can read the session. Optional; https in production.
BACKEND_URLapp (api), workerBrowser-facing api URL. Embedded in magic-link emails, and in the one-click unsubscribe links (/e/u/:token for the digest, /e/a/:token for alerts — SC-460, SC-459). Optional on the worker — absent, both jobs log a refusal and send nothing.
COOKIE_DOMAINapp (api)Cross-subdomain cookie scope. Leave unset for same-origin.
BETTER_AUTH_SECRETapp (api)32+ chars. Better-Auth session signing key.
JOBS_HMAC_SECRETapp (api), worker32+ chars. Signs the api’s wake ping to the worker and the admin audit log’s signature chain. Optional on the worker — only its wake endpoint needs it.
WORKER_WAKE_URLapp (api)Where the api pings the worker after a user enqueues, e.g. http://worker:8081. Optional — only a Postgres that scales to zero (Neon) needs it, because a suspend kills the worker’s LISTEN and a new job then waits for the worker’s idle poll, up to 10 minutes. Self-hosters leave it unset: their Postgres never suspends, so jobs already start at once.
WORKER_WAKE_PORTapp (worker)Port the worker’s wake endpoint listens on, on all interfaces. Optional — served only when JOBS_HMAC_SECRET is set too. Pair with WORKER_WAKE_URL.
SCREENSHOT_BOT_SECRETapp (api)32+ chars. Screenshot-bot sign-in bearer. Optional everywhere — unset endpoint refuses with 403, feature disabled. Set if you use a screenshot-capture pipeline.
ENCRYPTION_KEYpackage (@scani/security)≥32 chars (recommended: 64 hex chars from openssl rand -hex 32). AES-256-GCM. Must match api ↔ worker.
LOG_ID_PEPPERpackage (@scani/logging)16+ chars. ID-hashing pepper. Required in production.
WORKER_CONCURRENCYapp (worker)Max concurrent BullMQ jobs per worker. Default 4.
VariableOwnerWhat it does
SCANI_DEPLOYMENT_TIERcloud-client1: direct providers and local storage/mail. 2: cloud processing, local S3, cloud auth mail. 3 or unset preserves managed routing.
SCANI_CLOUD_URLapp (api, worker)Tier 1: local token search and metadata service at http://data-provider:8082. Tier 2: Scani Cloud for AI, prices, token lookup, supported wallet requests and constrained authentication email. Tier 2 S3 stays local.
SCANI_CLOUD_API_KEYapp (api, worker)Bearer presented to the data-provider.
DATA_PROVIDER_API_KEYapp (data-provider)Bearer the data-provider validates against.
CLOUD_MANAGEMENT_ENABLEDapp (data-provider)Tier 2/3 only. Enables cloud-management surface.
APP_AUTH_URLapp (data-provider)Base URL of the app api; the data-provider resolves console sessions at its /api/auth/get-session. Required when CLOUD_MANAGEMENT_ENABLED.
CLOUD_FRONTEND_ORIGINapp (data-provider)CORS origin for cloud-management console.
CLOUD_KEY_INITIAL_STATUSapp (data-provider)Status a key created in the cloud console starts in: active (default) or suspended. A suspended key is refused until something with access to the database sets it active, so leave it unset on a self-hosted deployment.
CLOUD_QUOTA_HOURLY_DEFAULTapp (data-provider)Requests allowed per API key per rolling hour. Enforced per key (the limiter is keyed by apiKeyId); the value is one global default — cloud_api_keys.quota_monthly_requests is not read on the request path. 0 or absent = no quota, so every key gets an unbounded budget on your provider accounts.
GLOBAL_HOURLY_USD_CAPapp (data-provider)Cumulative upstream USD per hour across all tenants. Trips a circuit breaker; further requests get 503 until the next hour-bucket. Decimals allowed. 0 or absent = no cap.
VITE_DATA_PROVIDER_URLcloud + landing (frontend)Where those SPAs send tRPC calls. Baked at build time. Empty is legal and means same-origin, which is how dev works through the Vite proxy.
DATA_PROVIDER_PROXY_TARGETcloud (vite dev server)What that dev proxy forwards to. Default http://localhost:8082. Dev only — the production build never reads it.
VariableOwnerWhat it does
S3_ENDPOINTpackage (@scani/storage)Server-side S3 endpoint.
S3_PUBLIC_ENDPOINTpackageURL baked into presigned URLs for the browser. Defaults to S3_ENDPOINT if unset; override when the bucket is fronted by a CDN with a different hostname.
S3_ACCESS_KEY_IDpackage
S3_SECRET_ACCESS_KEYpackage
S3_BUCKETpackageBucket name.
S3_REGIONpackageOptional. Defaults to auto (works for R2 + SeaweedFS). Set explicitly for AWS S3 (e.g. us-east-1).
BACKUP_BUCKETapp (worker)Archive bucket for the nightly db-backup job. Deliberately not S3_BUCKET: that one is the temp job-payload store, and the only offsite copy of the database should not share a bucket with a lifecycle rule. Unset means the job refuses on every fire and writes nothing.
VariableOwnerWhat it does
FASTMAIL_API_TOKENpackage (@scani/email)Fastmail JMAP token. Takes precedence over SMTP.
SMTP_URLpackagesmtp://user:pass@host:port for any SMTP server.
SMTP_FROMpackageThe from address for outbound mail.

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.

VariableOwnerWhat it does
VITE_TURNSTILE_SITE_KEYapp, landing (frontend)Public site key for the Turnstile widget on sign-in and the contact form. Baked at build time.
TURNSTILE_SECRETapi, data-providerVerifies the widget’s token before an unauthenticated request sends mail. A missing or rejected token is refused (403); Cloudflare unreachable is refused (503).
DIAGNOSTICS_TOKENapi, data-provider32+ chars. Optional. Bearer that unlocks the detailed bodies of /readyz, /health/db, /health/ws, /health/deep and the data-provider’s /health/r2. Unset, every caller gets status and per-check ok only. Use one value on both.
VariableOwnerWhat it does
LOG_LEVELpackage (@scani/logging)debug, info, warn, error. Default info. In production debug and trace refuse to start, because they log request inputs.
LOG_PRETTYpackagePretty-print. Default false in production.
LOG_COLORIZEpackageColourise pretty-printed logs. Default on in dev, off in prod.
LOG_TIMESTAMPpackageInclude timestamps. Default on. Set false to defer to the log aggregator.
LOG_SQL_QUERIESpackageLog Drizzle queries. Default false.
SCANI_MIGRATE_DEBUGmigrateSet to 1 to print the stack under a refused migration. By default the migrate step prints one line: the Postgres message, its code, table and constraint.
LOG_REQUEST_BODIESpackageLog inbound HTTP request bodies. Dev only — refuses to start with this on in production.
LOG_RESPONSE_BODIESpackageLog outbound HTTP response bodies. Dev only.
LOG_WEBSOCKET_MESSAGESpackageLog WebSocket frames. Default on.
SERVICE_NAMEappSet automatically by compose (api, worker, data-provider).
SERVICE_VERSIONappSet automatically by the build; surfaces in log records. When it is a full 40-character commit sha, the api, the worker’s wake port and the data-provider also answer GET /version.json with {"commit": "<sha>"}; anything else answers {}.
SCANI_RELEASE_VERSIONapp (build)Set by the image publish from its v* tag, e.g. 0.51.0. The app header and /version.json name it as the release. Unset on a local or self-built image, which reads Development; anything that is not bare semver fails the build.
AI_DEFAULT_PROVIDERapp (worker)Optional. Which AI provider the worker picks first. Defaults to openai, which is also the only AI provider any backend service registers.

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.

VariableProviderUnlocks
COINGECKO_API_KEYCoinGeckoPaid-tier crypto prices.
FINNHUB_API_KEYFinnhubPublic-equity prices.
OPENAI_API_KEYOpenAIScreenshot and document parsing. Unset → starting a parse is refused with PRECONDITION_FAILED.
ETHERSCAN_API_KEYEtherscan V2All EVM wallet balances + transactions.
HELIUS_API_KEYHeliusSolana balances + transactions.
GOOGLE_SHEETS_IDGoogle SheetsSheet ID for manual-asset pricing fallback. Optional.
GOOGLE_SERVICE_ACCOUNT_KEYGoogle SheetsBase64-encoded service-account JSON used to read the sheet above. Optional.
VariableOwnerWhat it does
SENTRY_DSNappServer-side Sentry. No DSN = no-op.
SENTRY_ENVIRONMENTappTag (production, staging).
SENTRY_RELEASEappRelease identifier.
FLY_MACHINE_IDpackage (@scani/db)The machine name written into each api_procedure_minutes row, so a memory spike on one machine can be matched to the procedures that ran there. Fly.io sets it; you do not. Unset, the host name is used.
VITE_SENTRY_DSNapp (frontend)Browser-side Sentry. Baked at build time.
VITE_SENTRY_ENABLEDapp (frontend)Enable client-side reporting.
VITE_API_URLapp, cloud (frontend)URL the SPA calls for /api. Bun-bundled image bakes /api.
VITE_APP_URLcloud, admin (frontend)Scani app origin the console sends signed-out visitors to (/auth?returnTo=). Required in production for cloud; the admin build falls back to https://app.scani.xyz. Baked at build time.
VITE_CLOUD_URLapp (frontend)Cloud console origin the app may return to after sign-in (/auth?returnTo=). Unset = no cross-origin return. Baked at build time.
VITE_ADMIN_URLapp (frontend)Admin app origin the app may return to after sign-in, beside VITE_CLOUD_URL. Unset = no return to the admin. Baked at build time.
SCANI_COMMITdocs site (build)Full 40-hex commit the docs are built from, written into every page as <meta name="scani-commit"> so a probe can tell which commit a host serves. Unset = no marker; anything else that is not a full sha fails the build.
API_UPSTREAMapp (frontend-app nginx)Inside the prod frontend-app image, nginx reverse-proxies /api/* → ${API_UPSTREAM}. Default http://api:3001 (compose network). Override when running frontend-app outside compose.
FRONTEND_PORTdocker-compose.prod.ymlHost port for the frontend-app container. Default 8080.
CSP_CONNECT_SRCapp (frontend-app nginx)connect-src for the Content-Security-Policy nginx sends. Default 'self', which is correct when the SPA reaches the API through the container’s own /api proxy. Set it when you serve the SPA and the API from different origins, e.g. 'self' https://api.example.com wss://api.example.com — the browser blocks XHR/WebSocket to any origin not listed. Leave the other CSP directives alone; they are fixed in the image.
ROBOTS_POLICYapp (frontend-app nginx)Whether a search engine may index this origin. noindex (default) keeps the SPA out of search results — robots.txt, the <meta name="robots"> and the X-Robots-Tag header all say so, and a crawler takes the most restrictive of the three. index-root flips all three together and offers the root document only: every other path returns the same SPA shell, so allowing the whole origin offers a crawler an unbounded space of duplicates rather than a site, and blocking /assets/ would index a page that has not rendered yet. Any other value refuses to start rather than guessing which direction you meant.

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:

LayerPortNotes
Host-side bun dev:api3001Default from .env.example (PORT=3001).
Dev compose api container8080 internal, 3011 hostCompose maps 3011:8080 and overrides PORT=8080 so the dev SPA at :5173 can reach the api at http://localhost:3011.
Prod compose api container3001 internal, no host portnginx inside frontend-app proxies to http://api:3001 over the compose network. Operators only expose frontend-app.

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.

All exposed by apps/backend/api (and surfaced via nginx as /api/* in prod compose):

PathWhat it doesWhen to use
/healthProcess liveness. 200 if the api process is up.Cheap k8s liveness probe.
/readyzReadiness. 200 only if DB + Redis + schema are all healthy. Returns 503 (with a per-check breakdown) if migrations haven’t been applied.k8s readiness probe; load-balancer upstream check; docker-compose.prod.yml api healthcheck.
/health/dbDB ping + pool stats (with the bearer).Operator debugging.
/health/wsWebSocket stats (with the bearer).Operator debugging.
/health/deepDB + schema-drift + Redis + S3 + AI. 200 ok / 503 degraded with a per-check breakdown. Storage is local in Tier 1/2 and follows managed routing otherwise. Also reports providerCredentials — which platform provider keys are absent — and costControls — which of the two spend bounds are enforcing, distinguishing a bound set to 0 (off) from one that was never set (unset). Both are reported, never gated, so neither can 503 a deployment that simply has not bought a key or has no external callers to bound.Deploy-time smoke test. NOT for traffic routing — slow.

/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).

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.

VariableRead byWhat it does
COMPOSE_PROJECT_NAMEdocker composeNamespaces a checkout’s containers, volumes and networks. Left unset, compose names the project after the directory leaf — scani in every worktree and in the primary checkout — so a second up does not conflict with the first, it ADOPTS and recreates its containers, and a down -v deletes its volumes.
SCANI_STACK_TAGcompose files that pin the tag of a built imageOne image tag per checkout, so the tree that built last does not decide which source every other stack runs.
<SERVICE>_HOST_PORTdocker-compose.ymlOne host port per published service (POSTGRES_HOST_PORT, API_HOST_PORT, FRONTEND_HOST_PORT, …). The primary checkout keeps the documented defaults; a linked worktree is offset by a multiple of 100.

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.

VariableRead byWhat it does
SCANI_DEMO_MODEapi, workerWhen exactly 1, the api serves one fictional portfolio to anonymous visitors with no session, refuses every tRPC mutation with FORBIDDEN, and does not mount /api/auth/*, the admin routes or the unsubscribe route at all. The worker arms the demo-reset schedule and nothing else. Any other value — true, yes, 0, blank — is off. The flag alone grants nothing: the api reads every email in users at boot and exits unless the only account there is the demo persona, so setting this against a production database takes the process down instead of opening a demo.
SCANI_DEMO_SIGNUP_URLapiAbsolute URL the demo’s “create your own account” link points at. Defaults to https://app.scani.xyz.

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.

VariableRead byWhat it does
STUB_AIapi, worker, data-providerWhen 1, registers a fixed-payload AI provider instead of calling a real one. Refused in production by each app’s env schema.
STUB_CHAIN_DATAapi, worker, data-providerWhen 1, registers a fixture chain provider ahead of the real ones, so wallet-address activity probes and balance fetches resolve locally instead of calling blockchain.info or mempool.space / Etherscan / a Solana RPC / TronGrid / Toncenter. Only fixture addresses report activity. Refused in production by all three env schemas.
ALLOW_REMOTE_TEST_DBpackages/business/domain/test-preload.tsEscape hatch for the guard that refuses to run the suite against a non-local DATABASE_URL. Repository tests truncate and roll back real tables, so pointing them at a remote branch is destructive — set to 1 only when you have deliberately provisioned a throwaway database.
SCANI_ALLOW_SHARED_TEST_DBpackages/business/domain/test-preload.tsEscape hatch for the one-suite-per-database lock. The preload takes a Postgres advisory lock on the test database and refuses to start when another suite already holds it: two suites on one database interfere in ways the output attributes to neither run (SC-370, SC-372). Set to 1 only to share a database deliberately — the supported way to run two suites at once is to give each its own database: docker compose exec -T postgres createdb -U scani scani_test_$$, then bun run db:migrate and bun run test with DATABASE_URL pointed at it.
API_BASE_URLe2e (Playwright fixtures)Base URL the e2e suite hits for tRPC requests. Defaults to the dev-compose api at http://localhost:3011.
PLAYWRIGHT_BASE_URLPlaywright configBase URL Playwright treats as the SPA origin. Defaults to http://localhost:5173.
MAILPIT_URLe2e (magic-link helper)Mailpit HTTP API used to read auth emails during sign-in. Default http://localhost:8026.
POSTGRES_CONTAINERapps/e2e/scripts/run.tsName of the Postgres container the e2e suite docker execs into. No default — the runner sets it in both modes, and apps/e2e/fixtures/db.ts refuses rather than guessing when it is absent. It used to fall back to a literal container name, which was correct on one machine and silently wrong everywhere else (SC-494).
E2E_DB_NAMEapps/e2e/scripts/run.tsThe database inside that container. Set beside POSTGRES_CONTAINER and subject to the same refusal. In Mode B the runner created the stack so it knows the name; in Mode A it reads the DATABASE_URL of whichever container publishes the api port, because the api that answered the health probe is the process under test and cannot disagree with itself.
E2E_DB_UNRESOLVEDapps/e2e/scripts/run.tsSet INSTEAD of the two above when Mode A could not determine the stack’s database — a host-side dev server with no container, or a docker that could not be asked. It carries the reason to the point of use, where the fixture throws it saying NO QUERY WAS MADE. Without that clause a reader assumes the query ran and returned nothing (SC-494).
SCANI_DEV_DBdocker-compose.yml, apps/e2e/scripts/run.tsThe per-worktree dev database, scani_dev_<label>_<hash> (SC-429). Compose defaults it to scani; bun dev:stack sets it. The e2e runner reads it only in Mode B, where it created the stack and therefore passed it.
KEEP_STACK_ON_FAILUREapps/e2e/scripts/run.tsWhen 1, leaves the docker-compose stack running after a failed e2e run so the operator can poke at it.
DATA_PROVIDER_URLapps/e2e/scripts/wait-for-stack.tsHealth endpoint the e2e runner polls before starting. Default http://localhost:8082.
SHOT_FRESHapps/e2e/fixtures/shots-setup.tsWhen 1, ignores the stored browser session and signs in again before capturing screenshots.
PW_VISUAL_WSapps/e2e/fixtures/visual-setup.tsWebSocket endpoint of the Playwright browser server the visual-baseline suite attaches to. apps/e2e/scripts/visual.ts starts that server inside the Playwright Docker image and sets this; the fixture THROWS when it is unset rather than falling back to a local browser, because a macOS-rendered baseline does not match a Linux-rendered one and a silent fallback would produce baselines nobody can reproduce.
VISUAL_FRESHapps/e2e/fixtures/visual-setup.tsWhen 1, ignores the stored session and reseeds a new user and portfolio before capturing baselines. Without it the suite reuses a valid stored session, so two runs compare the same portfolio rather than two different ones.
SCANI_VISUAL_STALL_DATAapps/e2e/visual/v3-screens.spec.tsWhen set, replaces window.fetch for the /trpc path with one that never settles and never opens a connection, so every screen renders its loading state while the network stays idle. It exists to make the calibration of MIN_BASELINE_RATIO re-takeable — a run under it writes twelve loading-state PNGs and is refused by name, and git checkout visual/__screenshots__/ puts them back. Never set it on a run whose baselines you intend to keep.
SCANI_ALLOW_BASELINE_SHRINKapps/e2e/visual/capture-size.tsComma-separated screen NAMES, clearing the SC-867 refusal for those screens only. That guard refuses a baseline rewritten to under 70% of the one it replaced, which is what a screen photographed while its data had not arrived measures. It takes names rather than 1 deliberately: a blanket flag set while looking at one red screen clears all twelve, and naming the screen is the smallest form of “I opened this image” a variable can carry.
COLD_BOOT_APIapps/e2e/scripts/measure-cold-boot.tsUpstream the cold-boot harness proxies /api and /trpc to. Default http://127.0.0.1:3099.
COLD_BOOT_API_LOGapps/e2e/scripts/measure-cold-boot.tsWhere that api writes its stdout. The harness reads the sign-in OTP out of it — an api started without an email transport prints the code rather than sending it.
COLD_BOOT_DISTapps/e2e/scripts/measure-cold-boot.tsA built dist/ to serve instead of apps/frontend/app/dist. How a before/after sweep serves a baseline build from a second worktree.

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 production
  1. 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 in apps/*/src/config/env.ts.
  2. Add it to root .env.example with an annotation.
  3. Add it to the relevant app’s .env.example (so scripts/sync-env.ts propagates it to the per-app .env).
  4. Validate it in the right loader.
  5. Document it on this page and on Required env or Optional keys.