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. Baked into the API image as a build arg (apps/api/Dockerfile, passed by deploy.yml), 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.
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
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
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
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