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.
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-4o.
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.
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.

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.