Developer docs
System

Environment & config

Every variable, who reads it, and what breaks without it.

docs/ENVIRONMENT.md

Every variable, who reads it, and what breaks without it.

The two rules

  1. Server values are read in exactly one file — apps/api/src/core/config.ts. Nothing else touches process.env, so every runtime requirement is discoverable in one place.
  2. NEXT_PUBLIC_* is public. Next inlines it into the browser bundle. It is not a place a secret can hide.

Required

Both of these deliberately have no default. A fallback would mean production secrets signed and encrypted with a key that lives in the source tree, so the API refuses to handle secrets without them.

VariableRead byWithout it
DATABASE_POOL_SIZEcore/config.ts → core/database-url.ts (applied in core/db.ts)Prisma's default pool of 2 × CPUs + 1 per process. Production sets 20 (api) and 10 (worker) in docker-compose.prod.yml; a connection_limit already in DATABASE_URL wins.
SLOW_QUERY_MScore/config.ts → core/slow-query.ts (attached in core/db.ts)Defaults to 200: Prisma statements at or over it are logged with duration and parameterised SQL (never parameter values). 0 disables the report.
TICK_WORKERS_HOSTcore/config.ts → core/tick-workers-host.ts; read by main.ts and worker.tsDefaults to api: the API process runs the 22 interval workers (what npm run dev needs, since it starts no worker). Production sets worker on both services so the timers leave the request path. Any other value refuses to boot.
SOURCE_COMMITcore/config.ts → core/build-info.ts; served by GET /versionThe full commit SHA the image was built from. Set at runtime by deploy-prod.yml / deploy-nonprod.yml from the release manifest, since the release images are built without build args (the apps/api/Dockerfile build arg remains for local builds); never set by hand. Unset serves commit: null; anything but 40 lowercase hex refuses to boot.
RELEASE_VERSIONcore/config.ts → core/build-info.ts; served by GET /versionThe vX.Y.Z release the deploy rolled. Set at runtime by the release deploy workflows (#383, #384); unset until then, which serves release: null. Anything but vMAJOR.MINOR.PATCH refuses to boot.
JWT_SECRETcore/auth.tsFalls back to a dev value — safe locally, catastrophic in production
ENCRYPTION_KEYcore/crypto.tsThe API throws on any operation involving a stored secret
DATABASE_URLcore/db.tsNothing works

Generate both with openssl rand -base64 32.

Infrastructure

VariableDefaultNotes
DATABASE_URL—Only @verjson/api ever opens it. The web app has no DB credentials by design.
TEST_DATABASE_URL—A separate database, truncated between test cases
AMQP_URLamqp://app:app@localhost:5673A down broker degrades features; it does not fail readiness
S3_*MinIO defaultsEndpoint, keys, bucket, region

Service addresses

VariableDefaultWhy it exists
API_PORT4000
PUBLIC_API_URLhttp://localhost:4000OAuth redirect URIs must match what the provider has registered byte-for-byte, so it cannot be inferred from the request — a proxy would change the host
WEB_URLhttp://localhost:3000Where to bounce the browser after a provider callback
CORS_ORIGINShttp://localhost:3000The web app is a different origin, so this is required rather than optional
TRUSTED_PROXY_COUNT0How many reverse proxies sit in front. Decides which X-Forwarded-For hop is the client — see S-005. Zero means the header is not believed at all
NEXT_PUBLIC_API_URLhttp://localhost:4000/api/v1Baked in at build time. Changing it means rebuilding the web image

Optional — absent keys disable a feature rather than crash

VariableDisables
DEMO_FORM_TOKENThe landing page "Get a demo" dialog's lead capture (served at GET /public/demo-settings, #400) — unset, the dialog says requests are not connected. Production fills it from the NEXT_PUBLIC_DEMO_FORM_TOKEN repository variable.
DEMO_CALENDLY_URLThe dialog's booking step — unset, it stops at "we'll be in touch". Production fills it from the NEXT_PUBLIC_CALENDLY_URL repository variable.
CLARITY_PROJECT_IDMicrosoft Clarity on OmniChan's own pages (served at GET /public/site-analytics) — unset, no Clarity tag loads. The id from the tag URL (clarity.ms/tag/<id>); a malformed value refuses the boot. Production fills it from the prod environment's CLARITY_PROJECT_ID variable; nonprod never sets it.
SOCIALCRAWL_API_KEYSocialCrawl (third-party) in the listening explorer and all listening tracking runs — terms can still be saved, nothing is fetched
REDDIT_CLIENT_ID / _SECRET / REDDIT_USER_AGENTReddit in the listening explorer (official Data API, app-only OAuth). The user agent must be unique: <platform>:<app id>:<version> (by /u/<name>)
LINKEDIN_ORGANIZATION_SCOPESSet 1 only after LinkedIn approves the Community Management API — adds the Company Page scopes to Connect. Before approval LinkedIn rejects the whole sign-in
GOOGLE_CLIENT_ID / _SECRETGoogle sign-in. The button is simply not shown.
GITHUB_CLIENT_ID / _SECRETGitHub sign-in and the repo integration — one consent covers both
GMAIL_CLIENT_ID / _SECRET"Connect Gmail" on a project's Email sending panel (F-184) — the panel is simply not shown. A Google web client with the Gmail API enabled and gmail.send on its consent screen; redirect URI ${PUBLIC_API_URL}/api/v1/gmail/oauth/callback. Until Google verifies the gmail.send scope, sign-in shows Google's "unverified app" screen and is capped at 100 users
GMAIL_DAILY_SEND_LIMITNothing — defaults to 500, Google's cap for a personal Gmail account. What one connected mailbox may send in a rolling 24h; a broadcast that would pass it is refused before anything is sent. Raise it for Workspace mailboxes (Google allows ~2,000)
STRIPE_SECRET_KEYBilling. The endpoints report 503 and everything runs on the free plan.
STRIPE_WEBHOOK_SECRETWebhook processing
STRIPE_PRICE_*One plan each. Mapped here so a price can be re-created without a code change
TRIGGER_SECRET_KEYDurable workflows. Agent runs fall back to our own worker — deliberately, so the approval gate never depends on a SaaS being reachable
GROQ_API_KEYAI drafting
ELEVENLABS_API_KEYVoiceover, and the editor's captions. Template videos (F-182) are voiced by ElevenLabs when it is set, with real word timings for captions
VIDEO_TEMPLATE_VOICEWho voices template videos: auto (default: ElevenLabs if its key is set, else OpenRouter, else a silent placeholder the video is marked with), elevenlabs, openrouter or mock
ELEVENLABS_VOICE_ID / ELEVENLABS_TTS_MODELThe template-video voice and model. Default voice is "Sarah", a default voice the free plan may use over the API; library voices (including the older "Rachel") need a paid plan or credits (402 otherwise)
SEMRUSH_API_KEY / APOLLO_API_KEYGrowth intelligence
DATAFORSEO_LOGIN / DATAFORSEO_PASSWORDKeyword rank checks against a real SERP (F-091). Both unset = the deterministic mock, so Keywords and Rankings show invented positions. Billed per lookup, and one keyword per tracked domain — a competitor multiplies the count by the keyword set
BACKLINKS_MOCK_MODE / BACKLINKS_MAX_CALLS_PER_TICK / BACKLINKS_ROW_LIMITBacklink monitoring, on the same DATAFORSEO_* credentials as rank tracking. One billed call per (project, domain) per sweep; BACKLINKS_ROW_LIMIT is the row tier and the main cost dial
BACKLINKS_PROVIDER_API_KEYDead. It gated a provider slot that threw, so setting it broke the weekly sweep rather than enabling real data. Ignored since 2026-09-18
SERP_MOCK_MODE / SERP_MAX_CALLS_PER_TICKForce the mock even with credentials present; and the per-tick ceiling on billed lookups (default 100, overflow deferred to the next tick)
WHATSAPP_BRIDGE_*The Baileys bridge

That pattern is consistent on purpose: a half-configured deployment should show fewer features, not broken ones.

Crawling

VariableDefaultWhy
SCRAPER_USER_AGENTVerjsonMarketingStudio/0.1
SCRAPER_CONTACT_URL—A contact URL in the user agent is the difference between a crawler a site owner can reach and one they simply block

Worker

VariableDefaultWhy
LISTENING_TICK_MS1h (module default)How often the listening sweep LOOKS for due terms — not how often a term runs (that is each term's cadence_hours)
LISTENING_MAX_CREDITS_PER_TICK60SocialCrawl credits one sweep may spend; terms over it stay due and run next tick
LISTENING_DEFAULT_CADENCE_HOURS24Cadence for a new term when none is given
POST_IMPORT_MAX_POSTS10000Most posts one Instagram post-history sync reads, newest first, 100 per Graph call. The default is Meta's own ceiling on /media (the 10,000 most recent), so a sync reads the whole reachable history. Removals of posts deleted on Instagram only happen when the read reached the end
POST_IMPORT_TICK_MS21600000 (6 h)How often the worker re-syncs every connected Instagram account and Facebook Page: new posts, fresh media URLs (Instagram's expire), deleted posts removed
LISTENING_MONTHLY_CREDITS_PER_ORG300SocialCrawl credits one organization may spend per calendar month (UTC) across scheduled runs, Run now and explorer searches; over it → 402 and the sweep waits. 0 disables spending
LISTENING_MAX_TERMS_PER_PROJECT10Tracked terms one project may hold
LISTENING_RETENTION_DAYS365Mentions no run has seen for this long, and runs this old, are purged by the hourly sweep
WORKER_KINDSallWhich queues an instance consumes, so a slow kind can get its own Deployment and replica count with no code change. An unknown value exits at boot rather than presenting later as "jobs are silently not running"
RATELIMIT_FORCE—1 enforces rate limits outside production, for testing them