# Configuration reference

> Every environment variable web, sandbox and realtime read, generated from the code.

Source: https://critlab.app/docs/self-hosting/configuration

critlab is configured entirely through environment variables. This page lists every variable the running code reads. It is generated from the source, so it cannot drift from what the services actually do.

## How to read this page

- **Services** says which process reads the variable: `web`, `sandbox`, `realtime`, the optional `renderer` (the headless Chrome, see Requirements), or the `migrate` job. Variables read by two services must have **the same value in both**, most importantly `SANDBOX_TOKEN_SECRET` (web, sandbox, realtime) and the `SANDBOX_*` URL settings (web and sandbox).
- **required** means the service will not start, or its core feature cannot work, without it.
- **secret** means treat it as a credential: keep it in a Kubernetes Secret or your secret manager, never in a ConfigMap, a values file or shell history.
- **Default** is what the code uses when the variable is unset. The Helm chart sets some values differently; the description says so where it matters.
- Boolean switches accept `1`, `true`, `yes` or `on` unless the description names another form.

With the Helm chart, most of these are set for you from values; anything else goes in through `web.extraEnv`, `sandbox.extraEnv` or `realtime.extraEnv`. In a source checkout, they live in the repository-root `.env` (start from `.env.example`).

## Origins and secrets

Where each process is reachable, and the secrets the processes share.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `AUTH_SECRET` | web, sandbox, realtime | — | required, secret | Session signing key; web will not serve sign-in without it. Also the last fallback for the internal lookup secret, the integrations key and the storage key. Generate with `openssl rand -base64 32`. |
| `SANDBOX_TOKEN_SECRET` | web, sandbox, realtime | — | required, secret | HMAC secret for sandbox access tokens and presence tokens, at least 32 characters. Must be identical in web, sandbox and realtime; sandbox and realtime refuse to start without it. Generate with `openssl rand -base64 48`. |
| `WEB_ORIGIN` | web, sandbox | `http://localhost:3000` | required | The public origin of the app, e.g. `https://critlab.example.com`. Used for links in emails, OAuth/SAML callback URLs, the SAML entity id and CSP, and as the base of the absolute addresses in page metadata (the Open Graph and Twitter images, canonical links): without it a shared link's preview image points at `http://localhost`. Pages that render per request follow the current value; the prerendered documentation pages keep the value present when `next build` ran. The sandbox uses it for its frame-ancestors check. |
| `INTERNAL_LOOKUP_SECRET` | web, sandbox, realtime | `DOMAIN_LOOKUP_SECRET, then AUTH_SECRET` | secret | Shared secret for `/api/internal/*`, which the sandbox calls to resolve a workspace's own bucket credentials. Set it only to rotate that lookup independently. Must be identical in web and sandbox. |
| `INTERNAL_ORIGIN` | web, sandbox | `WEB_ORIGIN` | — | Where web is reachable from inside the deployment, e.g. `http://web:3000`. The sandbox calls it to resolve a workspace's own bucket, and web's middleware calls it for custom-domain lookups. It is also where the headless harness is served from, unless `HEADLESS_HARNESS_ORIGIN` says otherwise. |
| `SHARE_ORIGIN` | web | `WEB_ORIGIN` | — | Origin used to build share links (`<SHARE_ORIGIN>/p/<slug>`). Only set it when share links live on a different hostname from the app. |
| `SITE_MODE` | web | `app` | — | `app` serves everything. `landing` serves only the public landing pages, the documentation, the legal documents and the newsletter signup: `/login`, `/signup` and `/login/sso` show a "Coming soon" page, every other page redirects to `/newsletter`, and every other `/api/**` call (sign-in included) answers 404. Read for each request. Any value other than `app` counts as `landing`, so a typo closes the site rather than opening it. |

## Postgres

The one database every process reads and writes.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `DATABASE_URL` | web, sandbox, migrate | — | required, secret | Postgres connection string, e.g. `postgres://critlab:…@postgres:5432/critlab`. Postgres 16 is what the images and chart ship with. |
| `DATABASE_IDLE_TIMEOUT` | web, sandbox | `30` | — | Seconds before an idle connection is closed. The default suits local development; set `300` in production so connections survive ordinary gaps between requests, especially with a managed database. |
| `DATABASE_POOL_MAX` | web, sandbox | `10` | — | Connections per process pool. Lower it when a pooler such as PgBouncer sits in front, or when many replicas share one database's connection limit. |

## Object storage

The platform bucket (any S3-compatible store) and the bring-your-own-bucket feature.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `S3_ACCESS_KEY` | web, sandbox | — | required, secret | Access key ID for the platform bucket. |
| `S3_BUCKET` | web, sandbox | — | required | The platform bucket's name. Create it before first start. |
| `S3_ENDPOINT` | web, sandbox | — | required | S3 API endpoint of the platform bucket: SeaweedFS (`http://seaweedfs:9000`), MinIO, Ceph RGW, AWS S3 or Cloudflare R2. |
| `S3_SECRET_KEY` | web, sandbox | — | required, secret | Secret access key for the platform bucket. |
| `DELETION_LEDGER_EXPORT_ACCESS_KEY` | web | — | secret | Access key ID for the ledger bucket. Give it Object Read & Write on that bucket only: the export writes, and `ops:reapply-deletions --from-store` lists and reads. |
| `DELETION_LEDGER_EXPORT_BUCKET` | web | — | — | Name of the ledger bucket the deletion ledger is exported to (`critlab-deletion-ledger` in the cloud). Required with the other `DELETION_LEDGER_EXPORT_*` values. |
| `DELETION_LEDGER_EXPORT_ENDPOINT` | web | — | — | S3 endpoint of the LEDGER bucket the deletion ledger is exported to every hour, as JSON Lines (Cloudflare R2 in the cloud, in the EU jurisdiction). Set the four required `DELETION_LEDGER_EXPORT_*` values together to turn the export on; with none set it is off and nothing is logged, with only some set web logs which are missing every hour. It is a bucket of its own with its own credentials: never the platform bucket, and never the Postgres backup bucket (the app holds no credential to the backups). The export is what lets `ops:reapply-deletions` delete again, after a restore of Postgres to an earlier point, what was deleted since. |
| `DELETION_LEDGER_EXPORT_FORCE_PATH_STYLE` | web | `true` | — | `true` addresses objects in the ledger bucket as `<endpoint>/<bucket>/<key>`. Only the exact string `false` turns it off. |
| `DELETION_LEDGER_EXPORT_PREFIX` | web | `deletion-ledger` | — | Key prefix inside the ledger bucket under which the hourly export objects (`<prefix>/<day>/<time>-<id>.jsonl`) are written. |
| `DELETION_LEDGER_EXPORT_REGION` | web | `auto` | — | Region sent with signed requests to the ledger bucket. `auto` is what Cloudflare R2 takes; a real S3 bucket needs its own region. |
| `DELETION_LEDGER_EXPORT_SECRET_KEY` | web | — | secret | Secret access key for the ledger bucket. |
| `S3_FORCE_PATH_STYLE` | web, sandbox | `true` | — | `true` addresses objects as `<endpoint>/<bucket>/<key>`, which SeaweedFS, MinIO and most self-hosted stores need. Only the exact string `true` enables it. |
| `S3_REGION` | web, sandbox | `us-east-1` | — | Region sent with signed requests. Most self-hosted stores accept any value. |
| `STORAGE_ALLOW_PRIVATE_ENDPOINTS` | web, sandbox | `0` | — | `1` lets a workspace's own bucket endpoint resolve to a private, loopback or link-local address. Needed when that store lives on your internal network. Cloud metadata addresses stay blocked either way. |
| `STORAGE_SECRET` | web | `AUTH_SECRET` | secret | Encrypts workspace-owned bucket credentials at rest (AES-256-GCM). Rotating it invalidates every stored bucket credential: each workspace has to re-enter its key pair. |

## Sandbox origin

How prototype URLs are shaped and served. web and sandbox must see identical values.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `CANVAS_CRAWL` | web | `on` | — | `off` (or `0`/`false`/`no`) switches the canvas view off (D-104): no canvas is built after a publish or on demand, and the canvas routes answer 404 `canvas_disabled`. Builds use the same headless browser as card thumbnails; set `off` on an install without one. Read per request. |
| `HEADLESS_BROWSER_MIN_VERSION` | renderer | — | — | Read by the renderer only (D-115). A four-part Chrome version (`157.0.8103.38`) that may RAISE the browser floor the code enforces; a lower or unreadable value is ignored with a log line. The Helm chart sets it from `renderer.minBrowserVersion`. |
| `HEADLESS_HARNESS_ORIGIN` | web, sandbox | `INTERNAL_ORIGIN` | — | Where the headless browser loads its harness page from, as that browser sees it (D-115 R6). Web signs and loads the harness on this origin, its request filter allows exactly that load, and the sandbox names it in `frame-ancestors` for headless renders, so web and the sandbox must have the same value. Set it when the browser cannot reach `INTERNAL_ORIGIN`: the production Compose file sets `http://renderer-gateway:8080`, the renderer gateway's harness listener. `INTERNAL_ORIGIN` keeps its other jobs. A bare http(s) origin only (no credentials, path, query or fragment). On web a wrong value is "no renderer" with one log line; the sandbox refuses to start. |
| `HEADLESS_RENDERER` | web | `on` | — | `off` (or `0`/`false`/`no`) says this install has no headless renderer (D-115): no browser is ever started, card thumbnails show their kind placeholder, HTML canvases wait (a queued canvas reads as `failed` with the reason), and an AI review run fails with the reason. Nothing is recorded against a version meanwhile, so everything that waited renders once it is switched on. Screenshot-set canvases need no browser and keep working. Without it, a missing or refused browser (no Chrome, or one below the minimum version) is detected at the first launch and retried once every 5 minutes. Set it together with `CANVAS_CRAWL=off` on an install without a browser. The Helm chart sets `off` itself while `renderer.enabled` is false (and leaves `CANVAS_CRAWL` to you). Read per call. |
| `HEADLESS_RENDERER_URL` | web | — | — | Where web leases its headless browser from (D-115 R5): the origin of `services/renderer` on its private network, or the renderer gateway in the production Compose file (`http://renderer-gateway:4020`). Unset (the default), web launches a local Chrome as before (development, tests). Set, web starts no browser of its own: each thumbnail, canvas build or AI review leases a fresh one (`POST /lease`), connects with Playwright's own protocol, checks the minimum browser version again and deletes the lease when done. A bare http(s) origin only (no credentials, path, query or fragment). Set but wrong, or without `RENDERER_SECRET`, is "no renderer" (one log line, retried every 5 minutes), never a local launch. A busy renderer (one browser at a time) records nothing and the job is retried within about a minute. Read per call. |
| `RENDERER_HOST_RESOLVER_RULES` | renderer | — | — | Chrome's `--host-resolver-rules` for the renderer, fixed at start and never per lease: MAP rules only, at most 8, such as `MAP *.s.example.com renderer-gateway` (the sandbox zone to the gateway). A bad rule stops the renderer at start with a line naming it. The Compose file and the Helm chart set it from the sandbox zone. |
| `RENDERER_PUBLIC_CERTIFICATE` | renderer | `off` | — | Exactly `1` says the gateway's certificate chains to a public CA, so no pin is needed: it silences the empty-pin warning. Together with `RENDERER_TRUSTED_SPKI` it is a contradiction and the renderer refuses to start. Unset, blank and `0` are off; anything else stops the renderer at start. |
| `RENDERER_RESOLVE_ONLY_GATEWAY` | renderer | `off` | — | Exactly `1` makes Chrome answer "not found" for every host except the targets of the MAP rules, so a lookup Chrome makes on its own cannot leave through the cluster DNS (D-115 R8). Needs at least one MAP rule. The Helm chart always sets it; the Compose file does not (its renderer network has no route out). Unset, blank and `0` are off; anything else stops the renderer at start. |
| `RENDERER_SECRET` | web, renderer | — | secret | The renderer's lease secret, at least 32 characters, the same value `services/renderer` runs with (D-115 R5). Required when `HEADLESS_RENDERER_URL` is set. Web sends it only as `Authorization: Bearer …` to that origin, never to the browser and never in a log. Not `AUTH_SECRET` and not the internal lookup secret: generate its own (`openssl rand -base64 32`). |
| `RENDERER_TRUSTED_SPKI` | renderer | — | — | Chrome's `--ignore-certificate-errors-spki-list` for the renderer: the base64 SHA-256 of the public key of the gateway's `*.<zone>` certificate, at most 4, comma separated (`infra/compose/scripts/gateway-cert.sh` prints it). Empty with host-resolver rules set logs a warning at start, because every render would come out blank. Not a secret. |
| `SANDBOX_COMPRESS_MAX_MB` | sandbox | `5` | — | The largest response body, in MiB, that the sandbox gzips or deflates on the fly. A bigger one, or a streamed body whose size is not known, is sent as stored. `0` turns on-the-fly compression off, for when an edge in front of the sandbox already compresses. |
| `SANDBOX_INTERNAL_PORT` | sandbox | `4001` | — | Port the sandbox service listens on. |
| `SANDBOX_OBJECT_CACHE_MAX_OBJECT_MB` | sandbox | `5` | — | Files larger than this (MiB) are never cached and always streamed from storage. |
| `SANDBOX_OBJECT_CACHE_MB` | sandbox | `128` | — | In-memory cache for package files, in MiB, consulted only after a request's token has been verified. `0` disables it. Responses still being sent can hold buffers up to half this size on top of the cache, so budget about 1.5 times this value. The Helm chart sets 64 to fit its 256Mi memory limit. |
| `SANDBOX_ORIGIN` | web, sandbox | `http://localhost:4000` | — | Path mode: the single sandbox origin. Host mode: only used in a startup log line and for requests that do not carry a valid label. |
| `SANDBOX_SCHEME` | web, sandbox | `http for a loopback zone, https otherwise` | — | `http` or `https` for per-version sandbox URLs. Real deployments leave it unset. |
| `SANDBOX_URL_MODE` | web, sandbox | `host` | — | `host`: every prototype version gets its own origin, `https://<label>.<SANDBOX_ZONE>/`, which needs a wildcard certificate. `path`: one origin, `<SANDBOX_ORIGIN>/s/<token>/v/<version>/`. Packages with root-absolute asset URLs (Vite, Astro, CRA, Next export) do not render in `path` mode. |
| `SANDBOX_ZONE` | web, sandbox | `s.localhost:4000` | — | Host mode only. The bare wildcard zone, e.g. `s.example.com` (no `*.`, no scheme). Must differ from the app's host. |

## Realtime

Presence, cursors, follow mode and live comment sync.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `REALTIME_MAX_CONNECTIONS` | realtime | `5000` | — | Most `/ws` connections one realtime process holds at once, whoever holds them. A connection over it is refused in the upgrade with 503, before it becomes a WebSocket. A ceiling, not a memory bound: each socket may hold up to 1 MiB of one frame in flight. A positive whole number; anything else falls back to the default. |
| `REALTIME_MAX_CONNECTIONS_PER_IP` | realtime | `64` | — | Most open `/ws` connections per client address (an IPv6 client counted by its /64), refused in the upgrade with 429 beyond it. Everybody behind one office NAT shares an address, so raise it for one big NAT or an internal deployment. The address is read the web app's way (`TRUSTED_PROXY_HOPS`, `TRUST_CF_CONNECTING_IP`); behind Cloudflare without `TRUST_CF_CONNECTING_IP=1` every visitor shares Cloudflare's addresses. A positive whole number; anything else falls back to the default. |
| `REALTIME_MAX_CONNECTIONS_PER_SUBJECT` | realtime | `32` | — | Most open `/ws` connections per presence-token subject: one signed-in person across all their tabs and prototypes, or one anonymous page view. One more is refused in the upgrade with 429. Prototypes open in a new tab, so a person with dozens of prototype tabs is normal; a hundred is not. A positive whole number; anything else falls back to the default. |
| `REALTIME_PORT` | realtime | `4010` | — | Port the realtime service listens on. It serves `/ws` and `/health`. |
| `REALTIME_URL` | web | `ws://localhost:4010` | — | The WebSocket URL browsers connect to, e.g. `wss://critlab.example.com/ws`. Also added to the app's CSP `connect-src`. |
| `REDIS_URL` | web, realtime | — | — | Redis for cross-instance presence fan-out and live comment sync. Unset: realtime keeps presence in memory (one replica only) and web cannot push comment changes live; viewers catch up when they refocus the tab. |

## Sign-in

Passwordless email codes plus optional Google and Microsoft sign-in.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `AUTH_REQUIRE_EMAIL_VERIFICATION` | web | `false` | — | `true` blocks sign-in until the address is verified. |
| `GOOGLE_CLIENT_ID` | web | — | — | Google sign-in. Both Google variables must be set or the button does not appear. Callback: `<WEB_ORIGIN>/api/auth/callback/google`. |
| `GOOGLE_CLIENT_SECRET` | web | — | secret | Google OAuth client secret. |
| `MICROSOFT_CLIENT_ID` | web | — | — | Microsoft sign-in. Both Microsoft variables must be set or the button does not appear. Callback: `<WEB_ORIGIN>/api/auth/callback/microsoft`. |
| `MICROSOFT_CLIENT_SECRET` | web | — | secret | Microsoft OAuth client secret. |
| `MICROSOFT_TENANT_ID` | web | `common` | — | `common` accepts any Microsoft account. Set a tenant GUID or domain to restrict sign-in to one organisation. |
| `SESSION_MAX_AGE_DAYS` | web | `7` | — | How long a signed-in session lasts, in whole days (1 to 365); anything else stops web at startup. The lifetime slides: it is pushed out to the full length whenever the person comes back after a day, so the default of 7 is an idle timeout of a week and only someone who stays away that long is asked to sign in again. Applies to every sign-in method and to the platform admin alike. Raise it (30, say) if convenience matters more to you, or shorten it if your security policy requires; sessions already issued keep their old expiry until their next refresh. |

## Email

Sign-in codes, invitations and notifications all go out through this transport.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `MAIL_FROM` | web | `critlab@localhost` | — | The From address, e.g. `critlab <login@example.com>`. Use an address your relay is allowed to send as. |
| `NEWSLETTER_SECRET` | web | `SANDBOX_TOKEN_SECRET` | secret | Signs the newsletter confirmation links (a double opt-in: the link is good for 24 hours). 32 or more characters. Unset: `SANDBOX_TOKEN_SECRET` signs them, so nothing new is needed. |
| `RESEND_API_KEY` | web | — | secret | Resend's HTTP API. Takes precedence over `SMTP_URL`. Used by critlab cloud; a self-hosted install that never sets it never contacts Resend. |
| `RESEND_AUDIENCE_ID` | web | — | — | The id of the Resend segment (the dashboard still calls it an audience) that confirmed newsletter addresses are added to. Needs `RESEND_API_KEY` too. Unset: a confirmed address is not stored, and in production the confirmation is refused rather than lost silently. |
| `SMTP_URL` | web | — | secret | Any SMTP relay, e.g. `smtp://user:pass@mail.example.com:587`. The self-hosted transport. Unset (and no Resend key): codes are printed to web's log instead of sent. |

## SAML single sign-on

Service-provider settings. Each workspace's identity provider is configured in the app, not here.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `SSO_ALLOW_PRIVATE_METADATA_HOSTS` | web | `0` | — | `1` lets an IdP metadata URL resolve to a private or loopback address, for an identity provider on your internal network. |
| `SSO_ENFORCE_GRACE_SECONDS` | web | `600` | — | After an owner turns on Require SSO from a non-SSO session, that session can still reach the Single sign-on settings page for this long, to undo a lockout. `0` disables the window. |
| `SSO_SP_ENTITY_ID` | web | `WEB_ORIGIN` | — | The SAML service-provider entity id (audience). Stored per connection when saved, so changing it never breaks a live workspace. |

## Custom share domains and host routing

Reviewer links on a workspace's own hostname, and which hosts count as this deployment.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `CF_API_TOKEN` | web | — | secret | Cloudflare for SaaS API token (cloud only). Needs `CF_ZONE_ID` too. |
| `CF_ZONE_ID` | web | — | — | Cloudflare for SaaS zone (cloud only). |
| `DOMAIN_CNAME_TARGET` | web | — | — | What customers CNAME their hostname to. Leave unset on self-hosted: verification then accepts any CNAME/A/AAAA record pointing at your ingress. |
| `DOMAIN_LOOKUP_SECRET` | web, sandbox, realtime | `AUTH_SECRET` | secret | Shared secret for the internal host-lookup route middleware calls for custom domains. |
| `DOMAIN_TLS_PROVIDER` | web | `caddy` | — | How certificates for custom share domains are issued: `caddy` (on-demand TLS via `/api/domains/tls-ask`), `cloudflare` (Cloudflare for SaaS) or `none`. The Helm chart sets `none`. |
| `EXTRA_PRIMARY_HOSTS` | web | — | — | Comma-separated extra hostnames that count as this deployment. Without it, a request on an unlisted name is treated as an unknown custom domain and gets a 404. |
| `TRUST_CF_CONNECTING_IP` | web, realtime | `false` | — | Read Cloudflare's `CF-Connecting-IP` header before `X-Forwarded-For`. Set `1` only when the origin accepts traffic from Cloudflare alone (a firewall to Cloudflare's ranges, or Authenticated Origin Pulls): otherwise anyone who can reach the origin can write that header. realtime reads it too, for its per-address `/ws` connection cap. |
| `TRUST_PROXY_HOST` | web | `false` | — | Trust `X-Forwarded-Host`. Set `1` only behind a proxy you control that always overwrites it (Caddy's `reverse_proxy`, an ingress controller). The Helm chart sets `true`. |
| `TRUSTED_PROXY_HOPS` | web, realtime | `1` | — | How many reverse proxies of yours sit in front of web, counted from the RIGHT of `X-Forwarded-For`. The client (for per-address rate limits and the address an audit row records) is the entry that many places from the right: `1` for one proxy (Caddy, an ingress controller), `2` for a CDN in front of an ingress — only if that proxy APPENDS to the CDN's header instead of replacing it (Caddy and ingress-nginx replace by default: Caddy needs `servers { trusted_proxies static <CDN ranges> }`, ingress-nginx needs `use-forwarded-headers: 'true'` and `compute-full-forwarded-for: 'true'`; with fewer entries than hops the app logs once and uses the rightmost) —, `0` to trust no header at all (every caller then shares one address, and a prototype's password form is limited per prototype only). The left-hand entries are whatever the client wrote and are never used. The same address keys Better Auth's sign-in rate limits and the `ip` recorded on a session. Only true if your proxies append or overwrite the header and nothing reaches web around them; with no proxy a direct client still chooses its own address. The Helm chart exposes it as `config.trustedProxyHops` and passes it to realtime too, which reads it for its per-address `/ws` connection cap (`REALTIME_MAX_CONNECTIONS_PER_IP`). With `iap.enabled` the chart gives web one more (oauth2-proxy appends the ingress pod's address); `config.webTrustedProxyHops` overrides that number for web alone. |

## Integrations

Slack-compatible webhooks and Linear/Jira issue sync. Unset = the integration is not offered. Linear and Jira are also off until ISSUE_TRACKERS_ENABLED=1.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `INTEGRATIONS_ALLOW_ANY_WEBHOOK_HOST` | web | `0` | — | `0` accepts only `https://hooks.slack.com/services/…` webhooks. `1` also accepts Slack-compatible hosts such as Mattermost or Rocket.Chat. Private and metadata addresses stay blocked either way. |
| `INTEGRATIONS_SECRET` | web | `AUTH_SECRET` | secret | Encrypts stored webhook URLs and tracker tokens at rest (AES-256-GCM). |
| `ISSUE_TRACKERS_ENABLED` | web | `0` | — | `1` offers Linear and Jira issue sync: Settings → Integrations shows the connect buttons for the providers whose OAuth app is registered below, a thread can become an issue, and a status change or a comment is mirrored both ways. `0` (the default; Linear and Jira are not part of the launch) hides every entry point, runs no sync in either direction, and answers `issue_trackers_disabled` (404) on the connect, callback, create-issue and inbound-webhook routes. Issues and links that already exist stay in the database and come back untouched when it is switched on again. Slack-compatible webhooks are not affected. Read for each request; a restart applies a change, no rebuild. |
| `JIRA_CLIENT_ID` | web | — | — | Jira Cloud OAuth 2.0 (3LO) app. Both Jira variables must be set to offer Jira. Redirect: `<WEB_ORIGIN>/api/integrations/oauth/callback/jira`. |
| `JIRA_CLIENT_SECRET` | web | — | secret | Jira OAuth app secret. |
| `LINEAR_CLIENT_ID` | web | — | — | Linear OAuth app. Both Linear variables must be set to offer Linear. Redirect: `<WEB_ORIGIN>/api/integrations/oauth/callback/linear`. |
| `LINEAR_CLIENT_SECRET` | web | — | secret | Linear OAuth app secret. |
| `LINEAR_WEBHOOK_SECRET` | web | — | secret | Verifies inbound Linear webhooks. Without it, status flows from critlab to Linear only. |

## Licence

The offline licence key that unlocks Team/Enterprise features on a self-hosted install.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `INSTANCE_ID` | web | — | — | A name for this deployment. Only matters for a licence issued bound to one instance; such a key is refused anywhere else. |
| `LICENSE_KEY` | web | — | secret | The whole licence token, one line starting `critlab-lic.`. Only read when `BILLING_PROVIDER` is unset or `none`. Re-read at most once a minute; no restart needed. |
| `LICENSE_KEY_FILE` | web | — | — | Path to a file holding the token (e.g. a mounted Secret). `LICENSE_KEY` wins when both are set. Also where Settings → License saves a pasted key, if the path is writable. |
| `LICENSE_PUBLIC_KEY` | web | — | — | Testing and evaluation only. Overrides the licence public key compiled into the build. Leave unset in production. |

## Upload limits

Size caps enforced at upload, with agent-readable errors.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `MAX_HTML_BYTES` | web | `10485760 (10 MiB)` | — | Largest single HTML file in a package, whatever the plan's package limit. A bigger page is refused at upload (`html_too_large`). |
| `MAX_PACKAGE_BYTES_FREE` | web | `26214400 (25 MiB)` | — | Largest package a Free-plan workspace may upload. |
| `MAX_PACKAGE_BYTES_PRO` | web | `104857600 (100 MiB)` | — | Largest package on paid plans. Raise your ingress body-size limit with it (the chart's `web.ingress.maxBodySize`). |
| `MAX_SCREENSHOT_BYTES` | web | `4194304 (4 MiB)` | — | Largest screenshot per image. Each thread's first comment carries two (viewport and element crop). |
| `MAX_VOICE_BYTES` | web | `10485760 (10 MiB)` | — | Largest voice note, roughly ten minutes of Opus audio. |

## Voice transcription

Audio is always stored; these decide whether and by whom it is transcribed.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `OPENAI_API_KEY` | web | — | secret | For `TRANSCRIBE_PROVIDER=openai` only. |
| `TRANSCRIBE_COMMAND` | web | — | — | For `command`: the transcriber, with `{input}` and `{output}` placeholders, e.g. `whisper-cli -m /models/ggml-base.en.bin -f {input} -nt -otxt -of {output}`. |
| `TRANSCRIBE_CONVERT_COMMAND` | web | — | — | For `command`: run first to convert browser audio, e.g. `ffmpeg -nostdin -y -i {input} -ar 16000 -ac 1 {output}.wav`. |
| `TRANSCRIBE_OPENAI_MODEL` | web | `gpt-4o-mini-transcribe` | — | Model for `TRANSCRIBE_PROVIDER=openai`. |
| `TRANSCRIBE_PROVIDER` | web | `none` | — | `none` stores audio without a transcript. `command` runs a local binary such as whisper.cpp with no outbound call. `openai` uses hosted Whisper (not air-gap safe). |

## Link previews

Page titles for links pasted into comments.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `LINK_PREVIEWS` | web | `on` | — | `off` (or `0`/`false`/`no`) stops every outbound title fetch for links pasted into comments; badges then show domain and path. Links to this instance still show the prototype name. Set `off` on air-gapped installs. |

## Legal links

Whether the sign-in screen, the marketing footer, the account menu, the viewer and the password page link to critlab's Terms and Privacy Policy, or to your own.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `LEGAL_LINKS` | web | `on for critlab cloud, off when BILLING_PROVIDER is unset or `none`` | — | `on` or `off` (also `1`/`0`/`true`/`false`) shows or hides every link to critlab's own Terms of Service and Privacy Policy: the sign-in sentence, the marketing footer's Legal column, the account menu, the viewer's help card and the password page. A self-hosted install shows none by default, so nobody is told to accept critlab's terms on your instance. `off` also hides the two URLs below. The one exception to "read when the server starts" is the footer of the prerendered documentation pages: it follows the value the image was **built** with (`docker build --build-arg LEGAL_LINKS=on`; a default build leaves it off, which matches the self-hosted default). |
| `LEGAL_PRIVACY_URL` | web | — | — | Your own Privacy Policy page, as an absolute `http(s)` URL or a path on this host. Setting it shows a Privacy link that points there, even on a self-hosted install. Unset = the link follows `LEGAL_LINKS`. |
| `LEGAL_TERMS_URL` | web | — | — | Your own Terms page, as an absolute `http(s)` URL or a path on this host. Setting it shows a Terms link that points there, even on a self-hosted install. Unset = the link follows `LEGAL_LINKS`. |

## Data retention

The sweep that deletes expired audit events, sessions, sign-in codes and cached link titles, and rolls product analytics events older than 13 months up into daily totals. web runs it hourly on its own (see Scheduled jobs); `POST /api/internal/retention` runs it on demand, and is what you call from your own cron when the built-in scheduler is off.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `RETENTION_AUDIT_MONTHS` | web | `13` | — | How long audit events are kept, in whole calendar months, counted from the event. The default is the period critlab's Privacy Policy states. `0` (or `off`, `never`, `forever`) keeps the audit log forever, which a self-hosted install may need when the log is its own compliance evidence. Any other value that is not a whole number of months (for example `13 months`) also keeps the log and logs a warning: a typo never deletes. The other periods are fixed: sessions 30 days after they expire, sign-in codes 1 day after, link titles 7 days (6 hours for a failed fetch). |
| `RETENTION_DISABLED` | web | `off` | — | `1` (or `true`/`yes`/`on`) makes the retention job do nothing, whether the built-in scheduler or `POST /api/internal/retention` runs it: no rule runs and the route says `mode: "disabled"`. Read on every run. Wins over `RETENTION_DRY_RUN`. To stop the scheduler running every job instead, use `SCHEDULER=off`. |
| `RETENTION_DRY_RUN` | web | `off` | — | `1` (or `true`/`yes`/`on`) makes the sweep count what it would delete and delete nothing: each rule reports `wouldDelete` and the server log carries the same counts. Use it to see what a first run would remove from an existing database. |

## Scheduled jobs

web runs its seven maintenance jobs itself, from a timer: the notification digest, the asset sweep, data retention, the review batch poll, the canvas backlog, the custom domain re-check and the deletion-ledger export. One replica at a time does each (a Postgres advisory lock). The `/api/internal/*` routes keep working either way; see Scheduled jobs under Health checks, monitoring and logs.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `SCHEDULER` | web | ``on` for a production server (every self-hosted install and critlab cloud); `off` under `next dev`, in tests and on the isolated e2e stack` | — | `on` or `off` (also `1`/`0`/`true`/`false`/`yes`/`no`). On, each web process runs the notification digest, the asset sweep and data retention hourly and the review batch poll and the canvas backlog every minute, calling the same functions as the `/api/internal/*` routes directly. The first run of each is 3 to 8 minutes after start, never on boot, and every run takes a Postgres advisory lock first, so with several replicas exactly one does each job at a time. `off` runs nothing here: set it when you call the `/api/internal/*` routes from your own cron (those keep working either way) or when you want the jobs paused. Read when the server starts; restart to change it. A value that is neither on nor off is ignored and the default applies. See Scheduled jobs under Health checks, monitoring and logs. |

## Error reporting

Off unless SENTRY_DSN is set: nothing starts and nothing leaves the process. Works with Sentry and with a self-hosted GlitchTip (same DSN format). Reports carry an exception type, stack frames and a route template; never messages, comments, URLs, cookies, request bodies or screenshots.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `SENTRY_AUTH_TOKEN` | web | — | secret | Build time only (a Docker build secret, never baked into an image): when present, `next build` uploads source maps to Sentry and deletes them from the output so they are never served. Absent = no maps are built and the build touches no network. Never set at runtime. |
| `SENTRY_DSN` | web, sandbox, realtime | — | — | Turns error reporting on: the DSN of your Sentry project, or of a self-hosted GlitchTip (same format). Unset or malformed = off: nothing initialises and no request leaves the process. web reports through its own `/api/diagnostics` route, so no page's CSP names a vendor host. Read when the server starts; restart to change it. Leave unset on air-gapped installs. |
| `SENTRY_ENVIRONMENT` | web, sandbox, realtime | ``production` when `NODE_ENV=production`, else `development`` | — | Tags every report with an environment (`production`, `staging`, …) so alerts can tell them apart. Only used when `SENTRY_DSN` is set. |
| `SENTRY_ORG` | web | — | — | Build time only: the Sentry organisation slug the source maps are uploaded to. Used only together with `SENTRY_AUTH_TOKEN`. |
| `SENTRY_PROJECT` | web | — | — | Build time only: the Sentry project slug the source maps are uploaded to. Used only together with `SENTRY_AUTH_TOKEN`. |
| `SENTRY_RELEASE` | web, sandbox, realtime | — | — | Tags reports with a release (a git sha or version) so a regression can be tied to a deploy. Also read at BUILD time as the name of the release the source maps are uploaded under. Optional; only used when `SENTRY_DSN` is set. |
| `SENTRY_URL` | web | `https://sentry.io` | — | Build time only: the Sentry API base for the source-map upload. The EU region is `https://de.sentry.io`; a self-hosted server sets its own. Used only together with `SENTRY_AUTH_TOKEN`. |

## Product analytics

First-party and server-side only: a small fixed set of events about how members and their agents use critlab, kept in your own Postgres. On for critlab cloud, off for every other install unless you switch it on. Nothing leaves your database, no cookie is set, and no reviewer, address or user agent is ever recorded.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `ANALYTICS` | web | ``on` when `BILLING_PROVIDER` is a real provider (critlab cloud), `off` when it is unset or `none` (self-hosted)` | — | `on` records first-party product events (D-087): ten server-side events (a sign-up, a workspace, an agent's first request, a publish, a thread opened or resolved, a thread bundle read through an API key, an invitation, a beta limit reached), each as one row with the event name, the time, the workspace id and a member's opaque user id or an agent's API key id. Never a reviewer, an IP address, a user agent, a comment, a URL or any free text; reviewers (link and password visitors) are not recorded at all. `off` writes nothing. Raw rows are kept 13 months, then rolled up into daily totals with no ids (the retention job; it runs whether or not this is on). There is no per-user opt-out. Read for each request; a restart applies a change, no rebuild. Leave it unset on a self-hosted install. After switching it on for an instance that already has accounts, run `pnpm --filter @critlab/web analytics:backfill` once (a dry run unless `--confirm`). |

## Process and build

Set by the container images; only change them when running from source.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `CRITLAB_SNIPPET_PATH` | web | — | — | Path to the staging snippet's built `snippet.js`. The web image sets it; only override for an unusual filesystem layout. |
| `NEXT_DIST_DIR` | web | `.next` | — | Next.js build directory. The web image sets `.next-build`; leave it alone. |
| `NEXT_RUNTIME` | web | — | — | Set by Next.js itself (`nodejs` or `edge`); read only so error reporting starts in the Node runtime. Never set it. |
| `NODE_ENV` | web | — | — | `production` in the images. Turns on sign-in rate limiting, refuses development-only settings and, unless `SCHEDULER` says otherwise, switches the built-in scheduled jobs on. |
| `PORT` | web | `3000` | — | Port web listens on. |
| `RENDERER_CHROME_PATH` | renderer | `/opt/chrome/chrome` | — | Renderer only: the path of Chrome for Testing. The renderer image sets it; only override when running from source. |
| `RENDERER_HOST` | renderer | `0.0.0.0` | — | Renderer only: the interface its lease API listens on, inside the container's private network. Leave it alone. |
| `RENDERER_PORT` | renderer | `4020` | — | Renderer only: the port its lease API listens on. Neither the Compose file nor the Helm chart sets it; the gateway and the chart's Services expect 4020. |

## Cloud billing (critlab cloud only)

Paddle checkout. A self-hosted install leaves BILLING_PROVIDER unset or `none` and needs none of the rest.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `BILLING_PROVIDER` | web | `none` | — | `none` for every self-hosted install: no checkout, plans are set by the operator, and Team/Enterprise features come from the licence key. `paddle` is critlab cloud. |
| `PADDLE_API_KEY` | web | — | secret | Cloud only. |
| `PADDLE_CLIENT_TOKEN` | web | — | — | Cloud only. Sent to the browser for checkout; not secret. |
| `PADDLE_ENV` | web | `sandbox` | — | Cloud only. `sandbox` or `live`. |
| `PADDLE_PRICE_CREDITS_10` | web | — | — | Cloud only. Paddle price id. |
| `PADDLE_PRICE_CREDITS_50` | web | — | — | Cloud only. Paddle price id. |
| `PADDLE_PRICE_PRO` | web | — | — | Cloud only. Paddle price id. |
| `PADDLE_PRICE_TEAM` | web | — | — | Cloud only. Paddle price id. |
| `PADDLE_WEBHOOK_SECRET` | web | — | secret | Cloud only. Verifies Paddle webhooks. |

## AI review (not available in this release)

Reserved for a feature that is switched off for launch. Leave every variable here unset.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `ANTHROPIC_API_KEY` | web | — | secret | Reserved. Leave unset. |
| `REVIEW_BATCH` | web | — | — | Reserved. Leave unset. |
| `REVIEW_MODEL` | web | — | — | Reserved. Leave unset. |
| `REVIEW_OPENAI_API_KEY` | web | — | secret | Reserved. Leave unset. |
| `REVIEW_OPENAI_BASE_URL` | web | — | — | Reserved. Leave unset. |
| `REVIEW_PROVIDER` | web | `none` | — | Reserved. Leave unset. |
| `REVIEW_UNMETERED` | web | — | — | Reserved. Leave unset. |

## Development and testing only

Seams for the local e2e stacks. Most are ignored outright on a non-loopback origin or in production; never set any of them on a real deployment.

| Variable | Services | Default | Flags | Description |
| --- | --- | --- | --- | --- |
| `AUTH_RATE_LIMIT` | web | — | — | `off` disables sign-in rate limiting, but only while `WEB_ORIGIN` is a loopback origin. Ignored on a real deployment. |
| `CRITLAB_DEV_KEEP_COMPILED_ROUTES` | web | — | — | `1` makes `next dev` keep every compiled route for a day instead of disposing it after 60 s without a hit. Set only by the e2e dev-hooks server (`scripts/e2e-stack.sh up --dev-hooks`); unset, `next.config.ts` adds no key. Has no effect in a production build. |
| `CRITLAB_E2E_STACK` | web | — | — | Marks an isolated e2e stack; refuses real model providers there, keeps the built-in scheduler off unless `SCHEDULER=on`, and (on a loopback `WEB_ORIGIN` only) lets a spec show the app shell an announced Terms version through a request header. |
| `ISSUE_PROVIDER_BASE_URL_OVERRIDE` | web | — | — | Points Linear/Jira API calls at a local fake. Ignored when `NODE_ENV=production`. |
| `LINK_PREVIEWS_ALLOW_PRIVATE_HOSTS` | web | — | — | Exact `ip:port` pairs a local test fixture listens on. Honoured only while `WEB_ORIGIN` is loopback. |
| `MAILER_DEV_OUTBOX` | web | — | — | Console transport also writes each message, sign-in codes included, to this file. Never set in production. |
| `REVIEW_FAKE_COST_MICROS` | web | — | — | Test fixture for a reserved feature. |
| `REVIEW_FAKE_DELAY_MS` | web | — | — | Test fixture for a reserved feature. |
| `REVIEW_FAKE_FINDINGS_FILE` | web | — | — | Test fixture for a reserved feature. |
| `SCHEDULER_TEST_FIRST_DELAY_MS` | web | — | — | Replaces the scheduler's 3 to 8 minute wait before each job's first run with this many milliseconds (100 to 86,400,000), give or take 20%. Needs `SCHEDULER_TEST_INTERVAL_MS` too; same restrictions. Never set on a real deployment. |
| `SCHEDULER_TEST_INTERVAL_MS` | web | — | — | Shortens every scheduled job's interval to this many milliseconds (100 to 86,400,000) for a manual check against the isolated e2e stack. Needs `SCHEDULER_TEST_FIRST_DELAY_MS` too. Ignored outright on a production server that is not the e2e stack, so it can never make retention run every few seconds. Never set on a real deployment. |
| `SSO_DNS_FAKE_FILE` | web | — | — | Answers SSO domain-verification DNS lookups from a JSON file. Honoured only while `WEB_ORIGIN` is loopback. |
| `TEST_DB_ALLOW` | web | — | — | Extra database names the test guard lets integration tests connect to. |
