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
- Server values are read in exactly one file —
apps/api/src/core/config.ts. Nothing else touchesprocess.env, so every runtime requirement is discoverable in one place. 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.
| Variable | Read by | Without it |
|---|---|---|
DATABASE_POOL_SIZE | core/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_MS | core/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_HOST | core/config.ts → core/tick-workers-host.ts; read by main.ts and worker.ts | Defaults 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_COMMIT | core/config.ts → core/build-info.ts; served by GET /version | The 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_VERSION | core/config.ts → core/build-info.ts; served by GET /version | The 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_SECRET | core/auth.ts | Falls back to a dev value — safe locally, catastrophic in production |
ENCRYPTION_KEY | core/crypto.ts | The API throws on any operation involving a stored secret |
DATABASE_URL | core/db.ts | Nothing works |
Generate both with openssl rand -base64 32.
Infrastructure
| Variable | Default | Notes |
|---|---|---|
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_URL | amqp://app:app@localhost:5673 | A down broker degrades features; it does not fail readiness |
S3_* | MinIO defaults | Endpoint, keys, bucket, region |
Service addresses
| Variable | Default | Why it exists |
|---|---|---|
API_PORT | 4000 | |
PUBLIC_API_URL | http://localhost:4000 | OAuth 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_URL | http://localhost:3000 | Where to bounce the browser after a provider callback |
CORS_ORIGINS | http://localhost:3000 | The web app is a different origin, so this is required rather than optional |
TRUSTED_PROXY_COUNT | 0 | How 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_URL | http://localhost:4000/api/v1 | Baked in at build time. Changing it means rebuilding the web image |
Optional — absent keys disable a feature rather than crash
| Variable | Disables |
|---|---|
DEMO_FORM_TOKEN | The 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_URL | The 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_KEY | SocialCrawl (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_AGENT | Reddit 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_SCOPES | Set 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 / _SECRET | Google sign-in. The button is simply not shown. |
GITHUB_CLIENT_ID / _SECRET | GitHub sign-in and the repo integration — one consent covers both |
STRIPE_SECRET_KEY | Billing. The endpoints report 503 and everything runs on the free plan. |
STRIPE_WEBHOOK_SECRET | Webhook processing |
STRIPE_PRICE_* | One plan each. Mapped here so a price can be re-created without a code change |
TRIGGER_SECRET_KEY | Durable workflows. Agent runs fall back to our own worker — deliberately, so the approval gate never depends on a SaaS being reachable |
GROQ_API_KEY | AI drafting |
ELEVENLABS_API_KEY | Voiceover |
SEMRUSH_API_KEY / APOLLO_API_KEY | Growth intelligence |
DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD | Keyword 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_LIMIT | Backlink 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_KEY | Dead. 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_TICK | Force 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
| Variable | Default | Why |
|---|---|---|
SCRAPER_USER_AGENT | VerjsonMarketingStudio/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
| Variable | Default | Why |
|---|---|---|
LISTENING_TICK_MS | 1h (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_TICK | 60 | SocialCrawl credits one sweep may spend; terms over it stay due and run next tick |
LISTENING_DEFAULT_CADENCE_HOURS | 24 | Cadence for a new term when none is given |
LISTENING_MONTHLY_CREDITS_PER_ORG | 300 | SocialCrawl 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_PROJECT | 10 | Tracked terms one project may hold |
LISTENING_RETENTION_DAYS | 365 | Mentions no run has seen for this long, and runs this old, are purged by the hourly sweep |
WORKER_KINDS | all | Which 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 |