# Environment Variables & Flags

Every CoreReflex knob is an environment variable — this page is the canonical registry of what each one does, where it is read, and what happens when it is unset.

Configuration is loaded by [`src/env.js`](../../src/env.js) (it reads `.env` for
local dev; on the fleet the Coolify app injects them). `loadEnvFile` never
overrides a value already present in `process.env`, so injected vars win over
`.env`. Boolean flags parsed through `boolEnv` accept `1`/`true`/`yes`/`on`
(case-insensitive); flags compared literally in code (noted per-var below) accept
only the exact string, usually `true` or `false`. Copy
[`.env.example`](../../.env.example) to `.env` and fill in the secrets.
**Never commit real values** — the tables below show placeholder names only.

A core design principle: **every integration is inert until configured.** With
no keys at all the app still runs end to end — AI generation falls back to a
deterministic dev mock, billing is off, email logs to the console, and the
self-hosted render / voice / lip-sync / upscale / clip-proxy seams return clear
`503`s or actionable errors instead of hanging.

## On this page

- [Connecting to the backends](#connecting-to-the-backends)
- [Core server & deploy identity](#core-server--deploy-identity)
- [PostgreSQL](#postgresql)
- [Auth & account security](#auth--account-security)
- [Secrets at rest & content credentials](#secrets-at-rest--content-credentials)
- [Email seam (usermails)](#email-seam-usermails)
- [Object storage (S3-compatible)](#object-storage-s3-compatible)
- [AI generation — model registry](#ai-generation--model-registry)
- [AI generation — providers & routing](#ai-generation--providers--routing)
- [Director, agent & Gemini knobs](#director-agent--gemini-knobs)
- [Billing (Stripe) & cost model](#billing-stripe--cost-model)
- [Feature gates & schedulers](#feature-gates--schedulers)
- [Self-hosted render engine](#self-hosted-render-engine)
- [Render-worker service env](#render-worker-service-env)
- [Worker-hosted media seams (audio-fx / upscale / remove-bg / vectorize / clip-proxy)](#worker-hosted-media-seams)
- [Auto-Clips pipeline](#auto-clips-pipeline)
- [Voice (synthesis, cloning, real-time agent)](#voice-synthesis-cloning-real-time-agent)
- [Persona / talking-head](#persona--talking-head-lip-sync-seam)
- [Chat & support](#chat--support)
- [Social publishing (GoHighLevel relay)](#social-publishing-gohighlevel-relay)
- [Analytics](#analytics)
- [Security & abuse limits](#security--abuse-limits)

## Connecting to the backends

The Postgres + MinIO backends live on the fleet's private WireGuard mesh. Two
ways in:

- **Local dev** — run [`scripts/db-tunnel.sh`](../../scripts/db-tunnel.sh), then
  use the `localhost` ports in `.env.example`.
- **On-fleet** — deployed as a Coolify app on `.207`, use the private host
  (`10.10.0.2`) + native ports (PG 5432, PgBouncer 6432, MinIO 9000) directly.

## Core server & deploy identity

Read in [`src/server.js`](../../src/server.js) and [`src/api.js`](../../src/api.js).

| Var | Purpose | Default |
|-----|---------|---------|
| `PORT` | HTTP port the studio server listens on. | `4173` |
| `SITE_URL` | Canonical public origin used for `robots.txt` / `sitemap.xml` generation (correct regardless of request host). | `https://corereflex.com` |
| `BUILD_VERSION` | **Deploy stamp.** `GET /api/health` returns it as `version`, which proves which build is actually live (Coolify can silently stall a redeploy on rapid back-to-back pushes). Set it in Coolify to the deploy's git SHA; unset, a hardcoded fallback constant in `api.js` is returned instead — set the env stamp in prod. | code constant |
| `GA_MEASUREMENT_ID` | Optional Google Analytics 4. **OFF unless set.** When set, pages load gtag (`public/js/analytics.js` gets `window.CR_GA_ID` injected) and the CSP `script-src` is widened to allow `googletagmanager.com` / `google-analytics.com`. First-party analytics (`POST /api/analytics/event`) runs regardless and needs no CSP change. | unset (off) |
| `HERO_CLIP_URL` | Optional curated background clip for the home hero. When set, the server injects `<meta name="cr-hero-clip">` into the served HTML `<head>` (value sanitized) and `public/home.html` pins the hero deconstruction to this one clip, crossfading it in over the procedural light-field. **Unset → hero uses the branded procedural light-field only.** The hero explicitly does NOT scrape the public `/api/gallery` (a top-ranked clip with baked-in text would collide with the headline). Use a clean full-frame 16:9 4K render with no baked-in text. | unset (procedural) |
| `HEADERS_TIMEOUT_MS` | Node `server.headersTimeout`. | `60000` |
| `REQUEST_TIMEOUT_MS` | Node `server.requestTimeout` (long uploads/renders need headroom). | `300000` |
| `EDITOR_URL` | Base URL of the editor app, health-checked by `checkEditorHealth` (`{EDITOR_URL}/healthz`). | `https://editor.corereflex.com` |
| `EDITOR_HEALTH_TIMEOUT_MS` | Editor health-probe timeout, clamped to 250–5000 ms. | `1800` |

## PostgreSQL

| Var | Purpose |
|-----|---------|
| `DATABASE_URL` | App connection (role `corereflex_app`, least-privilege). |
| `DATABASE_MIGRATION_URL` | Migrator role — used **only** by `npm run db:migrate`. |
| `DATABASE_READONLY_URL` | Read-only role for analytics/inspection. |
| `POSTGRES_HOST/PORT/DATABASE/USER` | Mirror the `DATABASE_URL` components (used by `scripts/db-tunnel.sh` / manual `psql`); the app itself connects via `DATABASE_URL` only — it does **not** fall back to these. |
| `POSTGRES_SSL` | `true` to require TLS. |
| `POSTGRES_POOL_MAX` | Pool size cap (also read by the render worker). |

## Auth & account security

| Var | Purpose | Default |
|-----|---------|---------|
| `JWT_SECRET` | HS256 signing key (≥64 random chars). Also signs the password-reset token and is the last-resort fallback for `SECRET_ENCRYPTION_KEY` / `CREDENTIAL_SIGNING_KEY` / `ASSET_TOKEN_SECRET` / `VOICE_TWILIO_SECRET`. | — (required) |
| `AUTH_COOKIE_NAME` | Session cookie name. | `corereflex_session` |
| `AUTH_COOKIE_DOMAIN` | Cookie `Domain=` attribute (shares the session across the apex + editor subdomain). | `.corereflex.com` |
| `AUTH_TOKEN_TTL_SECONDS` | Session lifetime. | `43200` (12h) |
| `APP_BASE_URL` | Public origin used to build links in transactional email (e.g. the password-reset `…/reset?token=` link). | `https://corereflex.com` |
| `PUBLIC_APP_URL` | Overrides `APP_BASE_URL` for the email-verification link base ([`src/email-verification.js`](../../src/email-verification.js)). | falls back to `APP_BASE_URL` |
| `PWRESET_TTL_SECONDS` | Lifetime of a password-reset token. The token is a signed JWT bound to a fingerprint of the user's current password hash, so it is single-use (stops working the instant the password changes) — see [`src/password-reset.js`](../../src/password-reset.js). | `1800` (30 min) |

## Secrets at rest & content credentials

[`src/crypto-box.js`](../../src/crypto-box.js) provides AES-256-GCM
encryption for recoverable third-party tokens (e.g. a workspace's GoHighLevel
key, stored by [`src/social.js`](../../src/social.js)) and the Ed25519 keypair
behind [`src/content-credentials.js`](../../src/content-credentials.js) — the
CoreReflex-signed provenance record on generated/rendered assets (explicitly
**not C2PA**; verifiable only at our own public endpoint).

| Var | Purpose | Default |
|-----|---------|---------|
| `SECRET_ENCRYPTION_KEY` | Root key (≥32 chars) for secrets-at-rest encryption. | falls back to `JWT_SECRET` |
| `CREDENTIAL_SIGNING_KEY` | Seed (≥16 chars) the content-credential Ed25519 keypair is derived from deterministically. **Set a dedicated value in prod** so rotating the auth secret can't invalidate previously minted credentials. Minting is best-effort — a missing key never fails a generation/render (`tryMintCredential` returns `null`). | falls back to `SECRET_ENCRYPTION_KEY` → `JWT_SECRET` |

## Email seam (usermails)

Outbound email ([`src/email.js`](../../src/email.js)) powers password reset,
email verification, and support notifications. **The usermails.com provider is
implemented** — it is the only one. Any other `EMAIL_PROVIDER` value makes
`sendEmail` throw `Unsupported EMAIL_PROVIDER` (the `RESEND`/`SENDGRID`/
`POSTMARK`/`SMTP` branches were never built; do not set those values).

| Var | Purpose | Default |
|-----|---------|---------|
| `EMAIL_PROVIDER` | **Unset:** sends are logged to the server console and report `delivered:false`, so flows degrade safely instead of leaking a token into an HTTP response. **`usermails`:** real sends via `https://api.usermails.com/emails` (Bearer key, optional idempotency-key header, 15 s timeout). **Anything else:** `sendEmail` throws. | unset (console log) |
| `USERMAILS_API_KEY` | Project-scoped usermails key (`um_live_…`, shown once — store as a secret). Required when `EMAIL_PROVIDER=usermails`; missing → throw. Also enables best-effort deliverability checks via `verifyEmailAddress` (`/verify`; returns `unknown` = allow when unconfigured or on any error, so signup never hard-fails on the verifier). | — |
| `EMAIL_FROM` | Verified sender. | `CoreReflex <noreply@corereflex.com>` |

## Object storage (S3-compatible)

`ASSET_PROVIDER` switches the backend: `inhouse` (self-hosted MinIO, the default)
or `cloudflare` (R2). The render worker reads the same `ASSET_*` family
([`render-worker/storage.js`](../../render-worker/storage.js)) to upload outputs.

| Var | Purpose |
|-----|---------|
| `ASSET_PROVIDER` | `inhouse` \| `cloudflare`. |
| `ASSET_BUCKET` | Bucket name. |
| `ASSET_ACCESS_KEY_ID` / `ASSET_SECRET_ACCESS_KEY` | Credentials. |
| `ASSET_REGION` / `ASSET_ENDPOINT` / `ASSET_FORCE_PATH_STYLE` | Endpoint config. |
| `ASSET_PUBLIC_BASE_URL` | Public URL base for serving assets. |
| `ASSET_CLOUDFLARE_*` | R2 profile (bucket / keys / endpoint / public base URL) — only when provider = `cloudflare`. |
| `ASSET_PROXY_BASE_URL` | Base URL of the app's asset proxy route used when signing proxied asset URLs ([`src/storage.js`](../../src/storage.js)). Default `https://corereflex.com/api/assets`; override for other environments. |
| `ASSET_TOKEN_SECRET` | Signs proxied-asset access tokens. | falls back to `JWT_SECRET` |
| `MAX_UPLOAD_BYTES` | Cap on streamed asset PUTs (`uploadAsset` path in `api.js`). Default `536870912` (512 MB). |
| `IMPORT_MAX_BYTES` | Cap on project import payloads ([`src/import.js`](../../src/import.js)). Default `268435456` (256 MB). |

## AI generation — model registry

[`src/models.js`](../../src/models.js) is the single source of truth for every
Vertex model the app uses — each env-overridable, so changing a model is one env
var, one place. Defaults are the current GA generation; `NEWEST_GENERATION` in
the same file lists suggested newest IDs (verify the exact slug in your Model
Garden first — preview IDs carry version/date suffixes that change).

| Var | Role | Default |
|-----|------|---------|
| `GEMINI_VERTEX_MODEL` | Director reasoning / agent loop / chat. | `gemini-2.5-flash` |
| `GEMINI_PRO_VERTEX_MODEL` | Deeper reasoning — selected automatically when the brief's quality tier is `high` (see routing below). | `gemini-2.5-pro` |
| `VEO_VERTEX_MODEL` | Video generation (3.1-* are preview). | `veo-3.0-generate-001` |
| `IMAGEN_VERTEX_MODEL` | Image generation. | `imagen-3.0-generate-002` |
| `IMAGEN_LOGO_MODEL` | Logo Studio generation model ([`src/imagen.js`](../../src/imagen.js)) — logo quality's biggest lever; general image gen stays on `IMAGEN_VERTEX_MODEL`. | `imagen-4.0-generate-001` |
| `IMAGEN_EDIT_MODEL` | Image AI editing (inpaint/outpaint capability model, [`src/image-edit.js`](../../src/image-edit.js)). | `imagen-3.0-capability-001` |
| `LYRIA_VERTEX_MODEL` | Music. | `lyria-002` |
| `EMBED_VERTEX_MODEL` | Embeddings (semantic recall). | `gemini-embedding-001` |
| `EMBED_DIM` | Embedding dimensionality ([`src/embeddings.js`](../../src/embeddings.js)) — must match the pgvector column. | `1536` |
| `LIVE_VERTEX_MODEL` | Real-time voice agent (Gemini Live). | `gemini-live-2.5-flash` |
| `CAPTIONS_STT_MODEL` | Speech-to-text for captions/transcripts. | `chirp-2` |
| `CAPTIONS_LANGUAGE` | Default STT language ([`src/captions.js`](../../src/captions.js)). | `en-US` |
| `GEMINI_VERTEX_LOCATION` / `IMAGEN_VERTEX_LOCATION` / `LYRIA_VERTEX_LOCATION` / `EMBED_VERTEX_LOCATION` / `LIVE_VERTEX_LOCATION` | Per-service Vertex location overrides (set `GEMINI_VERTEX_LOCATION=global` with a Gemini 3 model; Veo/Imagen/Lyria stay regional, typically `us-central1`). | service-specific |

**Quality routing** (`qualityTier` / `routeModel` in `models.js`): the brief text
is scanned for tier cues — a "masterpiece/cinematic/4k" ask gets the pro model +
a fidelity prompt modifier, a "draft/sketch/quick" ask stays fast/cheap.

| Var | Purpose | Default |
|-----|---------|---------|
| `QUALITY_HIGH_TRIGGERS` | Comma-separated phrases that select the `high` tier (pro model). | `masterpiece,award-winning,cinematic,photorealistic,hyper-realistic,hyperrealistic,ultra-detailed,highly detailed,4k,8k,premium,flagship,best quality` |
| `QUALITY_DRAFT_TRIGGERS` | Comma-separated phrases that select the `draft` tier. | `draft,sketch,rough,quick,wireframe,mockup,low-fi,lofi,placeholder,thumbnail,rough concept` |

## AI generation — providers & routing

With **neither** fal nor Vertex configured, the router falls back to a
deterministic dev mock, so the app runs without keys. In production the mock is
refused unless explicitly allowed.

| Var | Purpose |
|-----|---------|
| `FAL_KEY` | fal.ai aggregator — native-4K Kling (and Veo) via one key. |
| `KLING_MODEL_I2V` / `KLING_MODEL_T2V` / `VEO_MODEL_I2V` / `VEO_MODEL_T2V` | Optional fal model-slug overrides ([`src/providers.js`](../../src/providers.js)). |
| `KLING_CAMERA_CONTROL` | Literal `true` attaches the generated camera spec as Kling `camera_control` in the fal request ([`src/camera.js`](../../src/camera.js)); off by default because the exact fal schema varies by model. Veo gets camera moves via prompt text regardless. |
| `VEO_DURATIONS` | Allowed Veo clip durations (seconds, comma-separated). Default `4,6,8`. |
| `VERTEX_PROJECT` / `VERTEX_LOCATION` | Direct Vertex AI project/region. |
| `VEO_VERTEX_RESOLUTION` | Optional; only if the model accepts it. |
| `GCP_SERVICE_ACCOUNT_JSON` | One-line SA JSON (`roles/aiplatform.user`) — mints access tokens for every Vertex call. |
| `VERTEX_REQUEST_TIMEOUT_MS` | Per-request Vertex timeout ([`src/vertex.js`](../../src/vertex.js)). Default `60000`. |
| `VERTEX_RETRIES` | Vertex retry attempts. Default `3`. |
| `GENERATION_ALLOW_MOCK` | In `NODE_ENV=production` the dev mock provider is refused unless this is literal `true` (honesty gate — no fake generations in prod). |
| `MUSIC_MOOD` | Default Lyria mood preset ([`src/music.js`](../../src/music.js)). Default `cinematic`. |

> **Resolution note:** Veo is natively 1080p with a documented upscale path;
> Kling is natively 4K. See [`../MODEL-ROUTER.md`](../MODEL-ROUTER.md) and
> [`../UPSCALING-8K.md`](../UPSCALING-8K.md). Product copy says "native 4K";
> dev docs should keep the per-model distinction.

See [Generation & the Model Router](generation-and-models.md) for the full
pipeline. **`FRAME_EXTRACT_URL` is still unwired**: the producer's shot-to-shot
continuity chaining ([`src/producer.js`](../../src/producer.js)) degrades
gracefully — `extractLastFrame` returns `null`, so later shots fall back to
text-to-video until a fleet frame-extraction service exists. Setting the var
today does nothing; it is a reserved seam name, not a live flag.

## Director, agent & Gemini knobs

Read in [`src/director.js`](../../src/director.js),
[`src/gemini-cache.js`](../../src/gemini-cache.js), [`src/qa.js`](../../src/qa.js),
[`src/qa-analyzer.js`](../../src/qa-analyzer.js), [`src/agent.js`](../../src/agent.js).
Literal-string flags are noted.

| Var | Purpose | Default |
|-----|---------|---------|
| `DIRECTOR_EVAL` | Literal `true` runs the plan evaluator (`evaluatePlan`) on every Director plan and attaches `plan.eval`. | off |
| `DIRECTOR_GROUNDING` | Literal `true` enables Google Search grounding on Director planning. **Mutually exclusive with structured output** — grounding disables the response schema. | off |
| `DIRECTOR_STRUCTURED_OUTPUT` | Literal `false` disables the JSON response schema (only relevant when grounding is off). | on |
| `DIRECTOR_TEMPERATURE` | Plan generation temperature. | `0.7` |
| `DIRECTOR_KB_GROUNDING` | Literal `false` disables knowledge-base grounding of Director plans (retrieval from the workspace KB). | on |
| `DIRECTOR_KB_MIN_SCORE` | Minimum KB similarity score for a chunk to ground the plan. | code default |
| `DIRECTOR_KB_TIMEOUT_MS` | KB retrieval timeout. | `4000` |
| `GEMINI_THINKING_BUDGET` | When > 0, sets `thinkingConfig.thinkingBudget` on Director plan calls (Gemini thinking tokens). | `0` (off) |
| `GEMINI_TIMEOUT_MS` | Director Gemini call timeout. | `25000` |
| `GEMINI_CONTEXT_CACHE` | Literal `true` (case-insensitive) opts in to Vertex context caching for large repeated prefixes. Self-guarding: returns null (no cache) on any error. | off |
| `GEMINI_CACHE_TTL_SECONDS` | Cache entry TTL. | `3600` |
| `GEMINI_CACHE_MIN_TOKENS` | Minimum prefix size worth caching. | `1024` |
| `SHOT_QA_BAR` | Per-shot QA pass bar (fraction). | `0.75` (6 of 8 checks) |
| `QA_ANALYSIS_DISABLED` | Literal `true` disables media-grounded QA analysis. | on |
| `QA_ANALYZER_MAX_BYTES` | Max media size inlined into Gemini for QA; larger → `null` (honest skip). | code default |
| `QA_ANALYZER_TIMEOUT_MS` | QA analysis timeout. | `30000` |
| `EVAL_TIMEOUT_MS` / `ENHANCE_TIMEOUT_MS` / `WORKFLOW_TIMEOUT_MS` / `CAMPAIGN_TIMEOUT_MS` | Timeouts for plan eval / prompt enhance / workflow planner / campaign planner. | `20000` / `15000` / `20000` / `25000` |
| `AGENT_MAX_STEPS` | Agent kernel: hard cap on plan steps. | `200` |
| `AGENT_MAX_TOOL_TURNS` | Agent kernel: max tool-call turns per exchange. | `6` |
| `AGENT_MAX_PLAN_CREDITS` | Refuse plans estimated above this credit cost. | `5000` |
| `AGENT_CONFIRM_CREDITS` | Credits an agent may spend without explicit user confirmation. | `100` |
| `AGENT_MAX_UNCONFIRMED_GENERATIONS` | Generations allowed before requiring confirmation. | `8` |
| `AGENT_MAX_BULK_DELETE` | Max items a single agent bulk-delete may touch. | `25` |

## Billing (Stripe) & cost model

Inert until switched on. See [Billing](billing.md). Cost-model defaults live in
[`src/cost-model.js`](../../src/cost-model.js).

| Var | Purpose |
|-----|---------|
| `BILLING_ENABLED` | Master switch for credit metering (truthy = `true`/`1`). When off, `meterUsage` is a no-op. |
| `STRIPE_SECRET_KEY` | Enables checkout + the billing portal; presence sets `configured`. |
| `STRIPE_WEBHOOK_SECRET` | HMAC secret for verifying the `stripe-signature` header on `POST /api/billing/webhook`. |
| `STRIPE_WEBHOOK_TOLERANCE_S` | Replay defense: reject webhook signatures with timestamps outside this window. `0` disables. Default `300`. |
| `STRIPE_PRICE_PRO` / `STRIPE_PRICE_ULTRA` / `STRIPE_PRICE_ENTERPRISE` | Stripe price ids mapped to the Pro / Ultra / Enterprise plans (used by checkout and to map subscription prices back to a plan). |
| `STRIPE_PRICE_CREDITS_1K` / `_5K` / `_10K` / `_25K` | One-time credit pack Price ids (legacy: `STRIPE_PRICE_CREDITS_ADDON` → 1k). |
| `STRIPE_PRICE_STORAGE_50` / `_200` / `_500` / `_1T` | One-time storage pack Price ids (legacy: `STRIPE_PRICE_STORAGE_ADDON` → 50 GiB). |
| `STRIPE_PRICE_STORAGE_ADDON` / `STRIPE_PRICE_CREDITS_ADDON` | Price ids for the storage / credit add-ons (Brand cascade phase 5). |
| `CREDIT_PRICE_CENTS` | Revenue per credit (margin math). Default `5`. |
| `VENDOR_COST_<OP>_CENTS` | Estimated vendor cost per operation for the margin dashboard — `VEO`, `KLING`, `PERSONA`, `LYRIA`, `IMAGEN`, `VOICE`, `RENDER`, `AUDIO_FX`, `UPSCALE`, `REMOVE_BG`, `VECTORIZE`, `CONTENT`, `DIRECTOR`, `EMBEDDING`, `EMAIL` (e.g. `VENDOR_COST_VEO_CENTS=200`). Defaults are ballparks; tune them. |

## Feature gates & schedulers

| Var | Purpose | Default |
|-----|---------|---------|
| `CLIENT_OPS_ENABLED` | Force-enable the CRM / tasks / booking / automation suite regardless of plan (otherwise Pro+). See [Client-Ops](client-ops.md). | plan-gated |
| `BRAND_STUDIO_ENABLED` | Literal `true` force-enables Brand Studio regardless of plan (otherwise Pro+; [`src/brands.js`](../../src/brands.js)). | plan-gated |
| `AUTOMATION_SCHEDULER` | The scheduled-automation tick loop (durable runner for `schedule` triggers) is **on by default** for the single web instance. Set literal `false` to disable — e.g. if a dedicated cron hits the run path instead. Best-effort: with no DB configured the tick no-ops. | on |
| `AUTOMATION_TICK_MS` | Scheduler tick interval ([`src/automation.js`](../../src/automation.js)). | `60000` |

## Self-hosted render engine

Self-hosted 4K export runs as a polling service (`@remotion/renderer`) on the
fleet, behind the [`src/render-engine.js`](../../src/render-engine.js) adapter.
Production state and recovery: [Auto-Clips infra runbook](auto-clips-infra.md).

| Var | Purpose | Default |
|-----|---------|---------|
| `RENDER_ENABLED` | Honesty gate — `POST /api/render` (and everything that enqueues renders, including Auto-Clips) returns `503` ("being provisioned") unless this is **exactly `true`**, so jobs are never queued that no worker can drain. Durably set in the prod Coolify env store. | unset (off) |
| `RENDER_WORKER_URL` | The render service endpoint the adapter POSTs each job to. Absence is a clear, actionable error — never a silent hang. | — |
| `RENDER_MOCK` | Literal `true` on the adapter returns a deterministic placeholder render (e2e testing without the worker). The dashboard Connections panel shows `configured` when `RENDER_ENABLED=true` + `RENDER_MOCK=true` without a worker URL. | off |
| `RENDER_ENGINE` | Module path of the engine implementation loaded by [`src/render-worker.js`](../../src/render-worker.js) (swap seam). | `./render-engine.js` |
| `RENDER_TIMEOUT_MS` | Per-render request timeout. | `1800000` (30 min) |
| `RENDER_COST_ESTIMATE_CENTS` | Default per-render cost estimate stored on the job (finance-tunable; the worker may refine it on completion). | `0` |

## Render-worker service env

[`render-worker/worker.js`](../../render-worker/worker.js) is a separate
deployable (Docker on the App box) that polls `render_jobs` in Postgres,
bundles the Remotion composition, renders with ffmpeg, and uploads to MinIO.
Its bundled Remotion **must match the editor's Remotion version exactly**
(lockstep rule). It reads:

| Var | Purpose | Default |
|-----|---------|---------|
| `DATABASE_URL` (+ `POSTGRES_SSL`, `POSTGRES_POOL_MAX`) | Job-queue connection. | — |
| `POLL_INTERVAL_MS` | Queue poll interval. | `5000` |
| `RENDER_MAX_ATTEMPTS` | Retry cap per job (stuck `running` jobs are reclaimed after 15 min while under the cap). | `3` |
| `RENDER_OUTPUT_PREFIX` | Object-storage key prefix for outputs. | `renders/` |
| `PROVIDER_ROUTE` | Provenance label stamped on output asset metadata. | `self-hosted-remotion` |
| `REMOTION_ENTRY` | Remotion composition entry point. | `editor/src/remotion/index.ts` |
| `REMOTION_COMP_ID` | Composition id to render. | `Main` |
| `REMOTION_CONCURRENCY` | Render concurrency override. | Remotion default |
| `REMOTION_TIMEOUT_MS` | Per-render timeout. | `240000` |
| `REMOTION_X264_PRESET` | x264 preset. | `medium` |
| `ASSET_*` | Same storage family as the app (see [Object storage](#object-storage-s3-compatible)) — outputs upload with these credentials. | — |
| `RENDER_SHARED_SECRET` | Shared token gating every worker-hosted HTTP endpoint (below). | — |

## Worker-hosted media seams

Per-clip audio DSP, video upscale, background removal, vectorize, and the
Auto-Clips long-video proxy all run on the render worker (the only box with
ffmpeg/ffprobe). Every seam follows the same pattern:

- Set `<SEAM>_PORT` on the **worker** to start that HTTP endpoint (unset = disabled).
- Set `<SEAM>_URL` on the **app** pointing at it (unset = clear, actionable error — never a hang).
- Set `<SEAM>_MOCK=true` (literal) on the **app** for an e2e-testable passthrough without the worker.
- All endpoints require the shared `RENDER_SHARED_SECRET` (`x-render-token` header) — they fetch source URLs server-side, so an open endpoint would be an SSRF/DoS risk. The app's value must match the worker's.

> **Doc drift warning:** [own-the-stack.md](own-the-stack.md) refers to an
> `UPSCALE_ENGINE_URL` — **that variable does not exist in code.** The canonical
> app-side var is `UPSCALE_URL` (the adapter file is *named*
> `src/upscale-engine.js`, which is where the confusion comes from).

### Audio FX (`/audio-fx`)

| Var | Where | Purpose | Default |
|-----|-------|---------|---------|
| `AUDIO_FX_PORT` | worker | Start the DSP endpoint. | unset (off) |
| `AUDIO_FX_URL` | app | Worker base URL the app proxies to. | — |
| `AUDIO_FX_MOCK` | app | Literal `true` → passthrough mock. | off |
| `AUDIO_FX_TIMEOUT_MS` | app | Request timeout. | code default |
| `AUDIO_FX_MAX_BYTES` / `AUDIO_FX_KEY_PREFIX` | worker | Source-size cap / output key prefix. | code defaults |

### Own-tech upscale (`/upscale`)

Policy lives in [`src/upscale.js`](../../src/upscale.js) (`resolveUpscalePlan`:
auto mode lifts sub-1080 to 1080, never downscales, caps magnification at 4x);
the app adapter is [`src/upscale-engine.js`](../../src/upscale-engine.js); the
worker service is [`render-worker/upscale-service.js`](../../render-worker/upscale-service.js).
The result is always a **sibling asset** — the native source is never replaced.

| Var | Where | Purpose | Default |
|-----|-------|---------|---------|
| `UPSCALE_PORT` | worker | Start the upscale endpoint. | unset (off) |
| `UPSCALE_URL` | app | Worker base URL (`POST /upscale {sourceUrl, target, maxTier, workspaceId}`). | — |
| `UPSCALE_MOCK` | app | Literal `true` → passthrough mock (`backend: 'mock'`). | off |
| `UPSCALE_TIMEOUT_MS` | app | Request timeout. | `1800000` (30 min) |
| `UPSCALE_MAX_TIER` | worker | Hard ceiling tier (`720p`/`1080p`/`4k`/`8k`); requests above it clamp (never reject). **8K is opt-in** — set `8k` to allow it. | `4k` |
| `UPSCALER` | worker | Backend: `ffmpeg` (own-tech v1: lanczos + unsharp) or `sr` (self-hosted super-resolution). | `ffmpeg` |
| `SR_SERVICE_URL` | worker | Self-hosted SR service base URL (only used when `UPSCALER=sr`). Unconfigured → honest passthrough, never a fake upscale ([`render-worker/sr-backend.js`](../../render-worker/sr-backend.js)). | — |
| `SR_SERVICE_TOKEN` / `SR_TIMEOUT_MS` | worker | SR bearer token / timeout. | — / code default |
| `UPSCALE_CRF` / `UPSCALE_X264_PRESET` | worker | ffmpeg v1 quality knobs. | `18` / `medium` |
| `UPSCALE_MAX_BYTES` / `UPSCALE_KEY_PREFIX` | worker | Source-size cap / output key prefix. | code defaults |


### Long-form assemble (`/assemble`)

Concat pre-rendered scene MP4s (+ optional audio bed) on the render worker without a Remotion frame re-render. Soft on the app: unset `ASSEMBLE_URL` → recipe step `assemble.segments` skips.

| Var | Where | Purpose | Default |
|-----|-------|---------|---------|
| `ASSEMBLE_PORT` | worker | Start the endpoint. Prod: `8094`. | unset (off) |
| `ASSEMBLE_URL` | app | Worker base URL (`POST /assemble`). e.g. `http://corereflex-render-worker:8094`. | — |
| `ASSEMBLE_TIMEOUT_MS` | app | Request timeout. | `900000` (15 min) |
| `ASSEMBLE_MAX_SEGMENT_BYTES` / `ASSEMBLE_KEY_PREFIX` | worker | Source-size cap / output key prefix. | code defaults |

### Background removal (`/remove-bg`)

App adapter [`src/remove-bg-engine.js`](../../src/remove-bg-engine.js); worker
service [`render-worker/remove-bg-service.js`](../../render-worker/remove-bg-service.js),
which can call an optional GPU segmentation service. Returns a sibling asset.

| Var | Where | Purpose | Default |
|-----|-------|---------|---------|
| `REMOVE_BG_PORT` | worker | Start the endpoint. | unset (off) |
| `REMOVE_BG_URL` | app | Worker base URL. | — |
| `BG_REMOVE_MOCK` | app | Literal `true` → passthrough mock. **Note the inverted name** — it is `BG_REMOVE_MOCK`, not `REMOVE_BG_MOCK`. | off |
| `REMOVE_BG_TIMEOUT_MS` | app | Request timeout. | code default |
| `SEGMENT_SERVICE_URL` / `SEGMENT_SERVICE_TOKEN` / `SEGMENT_TIMEOUT_MS` | worker | Optional self-hosted GPU segmentation backend ([`render-worker/remove-bg.js`](../../render-worker/remove-bg.js)). | — |
| `REMOVE_BG_MAX_BYTES` / `REMOVE_BG_KEY_PREFIX` | worker | Source-size cap / output key prefix. | code defaults |

### Vectorize — logo → SVG (`/vectorize`)

App adapter [`src/vectorize-engine.js`](../../src/vectorize-engine.js); worker
service traces rasters to clean SVG (VTracer binary required on the worker).

| Var | Where | Purpose | Default |
|-----|-------|---------|---------|
| `VECTORIZE_PORT` | worker | Start the endpoint. | unset (off) |
| `VECTORIZE_ENABLED` | worker | Literal `true` required in addition to the port (gates on the tracer binary being present). | off |
| `VECTORIZE_URL` | app | Worker base URL. | — |
| `VECTORIZE_MOCK` | app | Literal `true` → passthrough mock. | off |
| `VECTORIZE_TIMEOUT_MS` | app | Request timeout. | code default |
| `VECTORIZE_MAX_BYTES` / `VECTORIZE_KEY_PREFIX` | worker | Source-size cap / output key prefix. | code defaults |

### Clip proxy — long-video prepare (`/clip-proxy`)

App adapter [`src/autoclips-proxy.js`](../../src/autoclips-proxy.js); worker
service [`render-worker/clip-proxy-service.js`](../../render-worker/clip-proxy-service.js)
builds a time-preserving 1-fps low-res proxy of a long source so Gemini can
analyze it within the inline byte cap. In prod this runs as a dedicated
`corereflex-clip-proxy` container — see the
[Auto-Clips infra runbook](auto-clips-infra.md).

| Var | Where | Purpose | Default |
|-----|-------|---------|---------|
| `CLIP_PROXY_PORT` | worker | Start the endpoint (prod: `9099`). | unset (off) |
| `CLIP_PROXY_URL` | app | Worker base URL (prod: `http://corereflex-clip-proxy:9099`). | — |
| `CLIP_PROXY_MOCK` | app | Literal `true` → passthrough (caller keeps the original source URL — fine for short sources that already inline). | off |
| `CLIP_PROXY_TIMEOUT_MS` | app | Request timeout. | `1800000` (30 min) |
| `CLIP_PROXY_FPS` / `CLIP_PROXY_HEIGHT` | either | Proxy frame rate / height (app may pass per-request; worker has env defaults). | code defaults (1 fps, low-res) |
| `CLIP_PROXY_CRF` / `CLIP_PROXY_AUDIO_K` | worker | Proxy encode quality / audio bitrate (kbps). | code defaults |
| `CLIP_PROXY_MAX_BYTES` / `CLIP_PROXY_KEY_PREFIX` | worker | Source-size cap / output key prefix. | code defaults |
| `CLIP_PROXY_FETCH_TIMEOUT_MS` / `CLIP_PROXY_FFMPEG_TIMEOUT_MS` | worker | Source-download / ffmpeg-pass timeouts. | code defaults |

## Auto-Clips pipeline

Detection, cutting, and publishing knobs for the long-form → shorts pipeline
([`src/highlights.js`](../../src/highlights.js),
[`src/auto-clips.js`](../../src/auto-clips.js),
[`src/caption-generate.js`](../../src/caption-generate.js)). The pipeline also
depends on `RENDER_ENABLED` and (for long sources) the clip-proxy seam above.

| Var | Purpose | Default |
|-----|---------|---------|
| `AUTOCLIPS_TIMEOUT_MS` | Gemini analysis timeout (highlight detection, caption generation). | `120000` |
| `AUTOCLIPS_MAX_INLINE_BYTES` | Max video bytes inlined into a Gemini request; longer sources need the clip proxy. | code default |
| `AUTOCLIPS_MIN_SECONDS` / `AUTOCLIPS_MAX_SECONDS` | Highlight length bounds (clamped: min ≥ 3). | `12` / `60` |
| `AUTOCLIPS_MIN_GAP_MS` | Minimum gap between detected highlights. | `500` |
| `AUTOCLIPS_BATCH_MAX` | Max clips created per `/api/auto-clips` call. | code default |
| `AUTOCLIPS_PUBLISH_MAX` | Cap on clips published to the library per run. | code default |

## Voice (synthesis, cloning, real-time agent)

The voice stack ([`src/voice.js`](../../src/voice.js),
[`src/voice-agent/`](../../src/voice-agent/)) supports a self-hosted open engine,
Google Chirp, and a real-time Gemini Live agent.

| Var | Purpose | Default |
|-----|---------|---------|
| `VOICE_ENGINE_URL` | Self-hosted open voice engine (XTTS-v2 / Fish-Speech / Chatterbox). The default path for **cloned** voices ("own the stack"). | unset (falls back to Chirp) |
| `VOICE_ENGINE` | Set to `selfhosted` to route non-cloned voiceover through `VOICE_ENGINE_URL` too. Cloned self-hosted personas use `VOICE_ENGINE_URL` automatically. | unset |
| `VOICE_DESIGN` | Set to `true` when the engine behind `VOICE_ENGINE_URL` supports **voice design from a text description** (a VoxCPM-class model). When set, `POST /api/voice/design` forwards the description to the engine; when unset, the description is mapped onto the nearest named Chirp HD voice (always works on pure Vertex). | unset |
| `VOICE_ENGINE_LABEL` | Public label for the self-hosted voice engine on `GET /api/voice/voices`. Per the disclosure policy (`planning/MODEL_DISCLOSURE_PROVENANCE.md`) the default is now **`CoreReflex Voice`** (self-hosted → white-labeled; the open engine name is not surfaced, only kept in the internal NOTICE). | `CoreReflex Voice` (self-hosted) / `Google Chirp 3 HD` (vendor) |
| `VOICE_CLONE_ENABLED` | Allow-list gate for Chirp 3 Instant Custom Voice cloning (requires the explicit consent script). | unset (off) |
| `VOICE_DEFAULT` | Default named Chirp 3 HD voice id (`charon` / `aoede` / `kore` / `puck` / `fenrir`). | `charon` |
| `VOICE_DEFAULT_CALENDAR` | Optional booking-calendar slug for voice-agent sessions that omit `?calendar=` (browser or Twilio). Without a slug, booking/lead tools return a speakable "line not connected" error rather than writing nowhere. | unset |
| `LIVE_VERTEX_MODEL` | Gemini Live model for the real-time voice bridge. | `gemini-live-2.5-flash` |
| `LIVE_VERTEX_LOCATION` | Vertex location for Gemini Live; the Live model slugs use the global endpoint. | `global` |
| `LIVE_INPUT_RATE` / `LIVE_OUTPUT_RATE` | PCM sample rates for client input and model output. | `16000` / `24000` |
| `VOICE_MAX_NEW_PER_IP` | Real-time agent: new live sessions per IP per minute on the unauthenticated browser (`/api/voice/live`) and Twilio media (`/api/voice/twilio/media`) WS bridges. | `5` |
| `VOICE_MAX_CONCURRENT` | Real-time agent: global cap on concurrent live sessions. | `50` |
| `VOICE_MAX_SESSION_MS` | Real-time agent: hard per-session lifetime before the bridge closes it. | `600000` (10 min) |
| `VOICE_IDLE_MS` | Real-time agent: idle timeout when no client audio/text arrives. | `90000` (90 s) |
| `VOICE_ALLOWED_ORIGINS` | Comma-separated extra origins allowed to open the WS bridge (same-origin is always allowed). | — |
| `VOICE_ALLOW_NO_ORIGIN` | Literal `true` allows WS connections with **no** Origin header (smoke tests); denied by default. | off |
| `TWILIO_AUTH_TOKEN` | Enables Twilio Voice webhook signature verification on `GET/POST /api/voice/twilio`. Local/dev can omit; production should set it. | unset |
| `VOICE_ALLOW_UNSIGNED` | Literal `true` lets **production** accept unsigned Twilio webhooks when `TWILIO_AUTH_TOKEN` is unset (otherwise prod rejects them; dev allows). | off |
| `VOICE_TWILIO_SECRET` | Signs Twilio session-continuation tokens. | falls back to `JWT_SECRET` |

## Persona / talking-head (lip-sync seam)

| Var | Purpose |
|-----|---------|
| `PERSONA_LIPSYNC_URL` | Talking-head / lip-sync provider for the persona backend (a self-hosted MuseTalk / SadTalker endpoint). Unset ⇒ persona render returns a clear "not configured" error; the dashboard Connections panel shows it as `pending`. See [`src/personas.js`](../../src/personas.js). |
| `PERSONA_LIPSYNC_MOCK` | Non-GPU e2e smoke mode for Persona Studio. When `true`, the lip-sync seam returns the reference image as the clip so the consent → voice → clip flow can be smoke-tested before a live GPU provider is attached. |

## Chat & support

Most chat behavior (model, personas, rate limits, widget copy) is configured in
code defaults + the `chat_settings` DB table (God Console-editable, migration
027) — see [`src/chat-config.js`](../../src/chat-config.js). Env vars cover the
transport knobs and the notification address:

| Var | Purpose | Default |
|-----|---------|---------|
| `SUPPORT_NOTIFY_EMAIL` | Where a human handoff / new bug ticket notifies (falls back to the platform owner). Also overridable at runtime via `chat_settings.support_notify_email`. | unset |
| `CHAT_REQUEST_TIMEOUT_MS` | Non-streaming Gemini chat call timeout ([`src/vertex.js`](../../src/vertex.js)). | `45000` |
| `CHAT_STREAM_TIMEOUT_MS` | Streaming (SSE) chat call timeout. | `60000` |
| `CHAT_KB_MIN_SIMILARITY` | Minimum vector similarity for a `chat_kb` chunk to count as retrieval context ([`src/chat-kb.js`](../../src/chat-kb.js)). | `0.35` |

## Social publishing (GoHighLevel relay)

The social relay ([`src/ghl.js`](../../src/ghl.js), [`src/social.js`](../../src/social.js))
publishes to Facebook/IG/X/LinkedIn/GMB/TikTok through a workspace's own
GoHighLevel account. The **credentials are per-workspace, not env**: each
workspace supplies a Bearer API key + location id via `/api/social/connection`,
stored encrypted at rest (see [Secrets at rest](#secrets-at-rest--content-credentials)).
Env only pins the API contract:

| Var | Purpose | Default |
|-----|---------|---------|
| `GHL_API_BASE` | GoHighLevel (LeadConnector) API host. Fixed host — not user-supplied, so not an SSRF surface. | `https://services.leadconnectorhq.com` |
| `GHL_API_VERSION` | Dated `Version` header GHL pins behavior to. | `2021-07-28` |

## Analytics

| Var | Purpose | Default |
|-----|---------|---------|
| `GA_MEASUREMENT_ID` | See [Core server](#core-server--deploy-identity) — opt-in GA4; also widens the CSP for Google's tag domains. | unset (off) |
| `ANALYTICS_IP_SALT` | Salt for hashing visitor IPs in first-party analytics ([`src/analytics.js`](../../src/analytics.js)). Set a private value in prod — the code default is a public constant, so hashes are linkable across deployments until you do. | code constant |

## Security & abuse limits

The 2026-06-23 hardening pass added request-size caps, an SSRF guard, a CSP, and
per-IP rate limits. Env-tunable knobs:

| Var | Purpose | Default |
|-----|---------|---------|
| `MAX_BODY_BYTES` | Max in-memory request body for JSON / raw reads (`readBodyBuffer` throws `413` over the cap). Asset PUTs stream straight to storage and are capped separately by `MAX_UPLOAD_BYTES`. | `1048576` (1 MiB) |
| `IMAGE_EDIT_MAX_BODY_BYTES` | Separate, larger body cap for image-edit payloads (inlined image bytes). | `12582912` (12 MB) |
| `RATE_LIMIT_MAX_KEYS` | Cap on tracked rate-limit keys (memory bound for the in-memory sliding window). | `20000` |
| `CORS_ALLOWED_ORIGINS` | Comma-separated origins allowed CORS access to the API. | `https://editor.corereflex.com` |

Baked-in protections (no env var):

- **Nonce-based CSP** on every response ([`src/server.js`](../../src/server.js)) —
  a fresh random nonce is generated per response and every inline `<script>` in
  served HTML is stamped with it on the way out, so `script-src` is
  `'self' 'nonce-…'` with **no `'unsafe-inline'`**: an injected inline script
  without the unguessable per-request nonce is refused by the browser (HTML is
  therefore never cacheable by shared proxies). `style-src` keeps
  `'unsafe-inline'` (pages rely on inline `style=""` attributes, which nonces
  cannot authorize) plus `fonts.googleapis.com`; `img-src`/`media-src`/`connect-src`
  allow `https:` (+ `data:`/`blob:`/`wss:` as needed); `object-src 'none'`,
  `base-uri 'self'`, `frame-ancestors 'self'`. Setting `GA_MEASUREMENT_ID`
  widens `script-src` for Google's tag domains only. Also sent:
  `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
  `Referrer-Policy: strict-origin-when-cross-origin`, HSTS
  (`includeSubDomains`, no preload), a restrictive `Permissions-Policy`
  (camera/mic = self for editor recording), and `Alt-Svc: clear` (kills cached
  broken HTTP/3 at the edge).
- **SSRF guard** ([`src/net-guard.js`](../../src/net-guard.js)) — `safePublicUrl`
  requires `https` and rejects loopback / private / link-local / cloud-metadata
  hosts (incl. IPv4-mapped + NAT64 IPv6 literals) for any user-supplied media URL
  the server fetches (voice-clone reference audio, persona reference images).
  Pair with fleet egress rules for full DNS-rebind coverage.
- **Per-IP rate limits** ([`src/rate-limit.js`](../../src/rate-limit.js)) — an
  in-memory sliding window keyed by `cf-connecting-ip` (Cloudflare fronts the
  fleet) then `x-forwarded-for`: e.g. login 10/min, register 5/min, forgot 5/min,
  generate 20/min, director-produce 6/min, public booking slots 60/min, book
  10/min. Per-process — swap for Redis if you ever run many replicas.
- **Tenant isolation** — every domain query is scoped to `user.workspace.id`;
  asset presign / persona refs re-check ownership so one tenant can't reach
  another's objects.
- **Worker endpoint auth** — every worker-hosted seam requires the shared
  `RENDER_SHARED_SECRET` (`x-render-token`); see
  [Worker-hosted media seams](#worker-hosted-media-seams).

Related: [Billing](billing.md) · [Client-Ops](client-ops.md) · [Deployment](deployment.md) · [Own the Stack](own-the-stack.md) · [Render Worker](render-worker.md) · [Auto-Clips Infra](auto-clips-infra.md) · [Generation & Models](generation-and-models.md).
