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.
FRONTEND_URLapp (api)Browser-facing SPA URL. CORS + cookie scope.
BACKEND_URLapp (api)Browser-facing api URL. Embedded in magic-link emails.
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)32+ chars. HMAC for operator job endpoints.
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_CLOUD_URLapp (api, worker)Where outbound third-party calls go. Tier 1: http://data-provider:8082. Tier 2/3: hosted endpoint.
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.
BETTER_AUTH_URLapp (data-provider)Public URL of the data-provider for cloud-management cookies.
CLOUD_FRONTEND_ORIGINapp (data-provider)CORS origin for cloud-management console.
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.
WAITLIST_IP_SALTapp (data-provider)Salt for the hashed client IP the waitlist.join rate limiter keys on, so the stored value is not a plain address. Defaults to a constant; set it in any deployment that takes public signups.

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.

VariableOwnerWhat it does
ADMIN_ORIGINadminPublic origin of the console. WebAuthn checks it, so a mismatch means every passkey assertion is rejected.
ADMIN_RP_IDadminWebAuthn Relying Party ID — the registrable domain of ADMIN_ORIGIN.
ADMIN_PASSKEY_CREDENTIAL_IDadminBase64 credential ID of the one enrolled passkey.
ADMIN_PASSKEY_PUBLIC_KEYadminIts public key. Together with the ID above, this is the user directory: there is no admin table.
ADMIN_SESSION_SECRETadminSigns the admin session cookie.
ADMIN_BOOTSTRAP_TOKENadminOne-time token that lets the first passkey enrol. Unset once a passkey exists — a live bootstrap token is a second way in.
NEXT_PUBLIC_SENTRY_DSNadminBrowser + server Sentry for the console. Unset → no-op.
NEXT_PUBLIC_SENTRY_ENVIRONMENTadminTag (production, preview).
NEXT_PUBLIC_SENTRY_RELEASEadminRelease identifier.
SENTRY_AUTH_TOKENadmin (build + API routes)Uploads source maps at build time, and backs the console’s “resolve issue” action. A write-scoped token, unlike the DSNs above.
SENTRY_ORGadmin (build)Sentry organisation slug for that upload.
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 + MinIO). Set explicitly for AWS S3 (e.g. us-east-1).
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.
VariableOwnerWhat it does
LOG_LEVELpackage (@scani/logging)debug, info, warn, error. Default info.
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.
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.
AI_DEFAULT_PROVIDERapp (worker)Optional. Which AI provider the worker picks first for screenshot parse / token-identity (openai, perplexity, deepseek). Defaults to openai.

In Tier 1 these live on your data-provider; in Tier 2/3 on the hosted data-provider.

VariableProviderUnlocks
COINGECKO_API_KEYCoinGeckoPaid-tier crypto prices.
FINNHUB_API_KEYFinnhubPublic-equity prices.
OPENAI_API_KEYOpenAIScreenshot parsing.
OPENAI_VISION_MODELOpenAIModel selection. Default gpt-5.6-luna.
PERPLEXITY_API_KEYPerplexityToken-identity enrichment. Optional.
DEEPSEEK_API_KEYDeepSeekToken-identity enrichment. Optional.
ETHERSCAN_API_KEYEtherscan V2All EVM wallet balances + transactions.
HELIUS_API_KEYHeliusSolana balances + transactions.
BINANCE_OAUTH_CLIENT_IDBinanceOAuth flow.
BINANCE_OAUTH_CLIENT_SECRETBinanceOAuth flow.
BINANCE_OAUTH_REDIRECT_URIBinanceOAuth callback URL (e.g. https://api.your-domain.example.com/auth/binance/callback).
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.
VITE_SENTRY_DSNapp (frontend)Browser-side Sentry. Baked at build time.
VITE_SENTRY_ENABLEDapp (frontend)Enable client-side reporting.
VITE_API_URLapp (frontend)URL the SPA calls for /api. Bun-bundled image bakes /api.
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.

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.Operator debugging.
/health/wsWebSocket stats.Operator debugging.
/health/deepDB + Redis + R2 + AI.Deploy-time smoke test. NOT for traffic routing — slow.

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

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_AIdata-provider (ai.parseScreenshot)When 1, returns a fixed holdings payload instead of calling a real AI provider. Refused in production by the data-provider env schema.
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.
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_CONTAINERe2e (db reset helper)Compose service name of the Postgres container the e2e suite execs into. Default postgres.
REDIS_CONTAINERe2e (queue reset helper)Compose service name of the Redis container the e2e suite execs into. Default redis.
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.
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:

  • isProductionprocess.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.