Self-hosting
Configuration reference
Every environment variable web, sandbox and realtime read, generated from the code.
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 optionalrenderer(the headless Chrome, see Requirements), or themigratejob. Variables read by two services must have the same value in both, most importantlySANDBOX_TOKEN_SECRET(web, sandbox, realtime) and theSANDBOX_*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,yesoronunless 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).
148 variables across web, sandbox, realtime and the migration job. Generated from the code by pnpm --filter @critlab/web docs:env; a test fails when code and this page disagree.
Origins and secrets
Where each process is reachable, and the secrets the processes share.
AUTH_SECRETrequiredsecretweb · sandbox · realtimeSession 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_SECRETrequiredsecretweb · sandbox · realtimeHMAC 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_ORIGINrequiredweb · sandboxThe 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.
Default: http://localhost:3000
INTERNAL_LOOKUP_SECRETsecretweb · sandbox · realtimeShared 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.
Default: DOMAIN_LOOKUP_SECRET, then AUTH_SECRET
INTERNAL_ORIGINweb · sandboxWhere 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.
Default: WEB_ORIGIN
SITE_MODEwebapp 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.
Default: app
Postgres
The one database every process reads and writes.
DATABASE_URLrequiredsecretweb · sandbox · migratePostgres connection string, e.g. postgres://critlab:…@postgres:5432/critlab. Postgres 16 is what the images and chart ship with.
DATABASE_IDLE_TIMEOUTweb · sandboxSeconds 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.
Default: 30
DATABASE_POOL_MAXweb · sandboxConnections per process pool. Lower it when a pooler such as PgBouncer sits in front, or when many replicas share one database's connection limit.
Default: 10
Object storage
The platform bucket (any S3-compatible store) and the bring-your-own-bucket feature.
S3_ACCESS_KEYrequiredsecretweb · sandboxAccess key ID for the platform bucket.
S3_BUCKETrequiredweb · sandboxThe platform bucket's name. Create it before first start.
S3_ENDPOINTrequiredweb · sandboxS3 API endpoint of the platform bucket: SeaweedFS (http://seaweedfs:9000), MinIO, Ceph RGW, AWS S3 or Cloudflare R2.
S3_SECRET_KEYrequiredsecretweb · sandboxSecret access key for the platform bucket.
DELETION_LEDGER_EXPORT_ACCESS_KEYsecretwebAccess 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_BUCKETwebName 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_ENDPOINTwebS3 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_STYLEwebtrue addresses objects in the ledger bucket as <endpoint>/<bucket>/<key>. Only the exact string false turns it off.
Default: true
DELETION_LEDGER_EXPORT_PREFIXwebKey prefix inside the ledger bucket under which the hourly export objects (<prefix>/<day>/<time>-<id>.jsonl) are written.
Default: deletion-ledger
DELETION_LEDGER_EXPORT_REGIONwebRegion sent with signed requests to the ledger bucket. auto is what Cloudflare R2 takes; a real S3 bucket needs its own region.
Default: auto
DELETION_LEDGER_EXPORT_SECRET_KEYsecretwebSecret access key for the ledger bucket.
S3_FORCE_PATH_STYLEweb · sandboxtrue addresses objects as <endpoint>/<bucket>/<key>, which SeaweedFS, MinIO and most self-hosted stores need. Only the exact string true enables it.
Default: true
S3_REGIONweb · sandboxRegion sent with signed requests. Most self-hosted stores accept any value.
Default: us-east-1
STORAGE_ALLOW_PRIVATE_ENDPOINTSweb · sandbox1 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.
Default: 0
STORAGE_SECRETsecretwebEncrypts 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.
Default: AUTH_SECRET
Sandbox origin
How prototype URLs are shaped and served. web and sandbox must see identical values.
CANVAS_CRAWLweboff (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.
Default: on
HEADLESS_BROWSER_MIN_VERSIONrendererRead 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_ORIGINweb · sandboxWhere 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.
Default: INTERNAL_ORIGIN
HEADLESS_RENDERERweboff (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.
Default: on
HEADLESS_RENDERER_URLwebWhere 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_RULESrendererChrome'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_CERTIFICATErendererExactly 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.
Default: off
RENDERER_RESOLVE_ONLY_GATEWAYrendererExactly 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.
Default: off
RENDERER_SECRETsecretweb · rendererThe 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_SPKIrendererChrome'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_MBsandboxThe 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.
Default: 5
SANDBOX_INTERNAL_PORTsandboxPort the sandbox service listens on.
Default: 4001
SANDBOX_OBJECT_CACHE_MAX_OBJECT_MBsandboxFiles larger than this (MiB) are never cached and always streamed from storage.
Default: 5
SANDBOX_OBJECT_CACHE_MBsandboxIn-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.
Default: 128
SANDBOX_ORIGINweb · sandboxPath mode: the single sandbox origin. Host mode: only used in a startup log line and for requests that do not carry a valid label.
Default: http://localhost:4000
SANDBOX_SCHEMEweb · sandboxhttp or https for per-version sandbox URLs. Real deployments leave it unset.
Default: http for a loopback zone, https otherwise
SANDBOX_URL_MODEweb · sandboxhost: 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.
Default: host
SANDBOX_ZONEweb · sandboxHost mode only. The bare wildcard zone, e.g. s.example.com (no *., no scheme). Must differ from the app's host.
Default: s.localhost:4000
Realtime
Presence, cursors, follow mode and live comment sync.
REALTIME_MAX_CONNECTIONSrealtimeMost /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.
Default: 5000
REALTIME_MAX_CONNECTIONS_PER_IPrealtimeMost 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.
Default: 64
REALTIME_MAX_CONNECTIONS_PER_SUBJECTrealtimeMost 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.
Default: 32
REALTIME_PORTrealtimePort the realtime service listens on. It serves /ws and /health.
Default: 4010
REALTIME_URLwebThe WebSocket URL browsers connect to, e.g. wss://critlab.example.com/ws. Also added to the app's CSP connect-src.
Default: ws://localhost:4010
REDIS_URLweb · realtimeRedis 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.
AUTH_REQUIRE_EMAIL_VERIFICATIONwebtrue blocks sign-in until the address is verified.
Default: false
GOOGLE_CLIENT_IDwebGoogle sign-in. Both Google variables must be set or the button does not appear. Callback: <WEB_ORIGIN>/api/auth/callback/google.
GOOGLE_CLIENT_SECRETsecretwebGoogle OAuth client secret.
MICROSOFT_CLIENT_IDwebMicrosoft sign-in. Both Microsoft variables must be set or the button does not appear. Callback: <WEB_ORIGIN>/api/auth/callback/microsoft.
MICROSOFT_CLIENT_SECRETsecretwebMicrosoft OAuth client secret.
MICROSOFT_TENANT_IDwebcommon accepts any Microsoft account. Set a tenant GUID or domain to restrict sign-in to one organisation.
Default: common
SESSION_MAX_AGE_DAYSwebHow 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.
Default: 7
Sign-in codes, invitations and notifications all go out through this transport.
MAIL_FROMwebThe From address, e.g. critlab <login@example.com>. Use an address your relay is allowed to send as.
Default: critlab@localhost
RESEND_API_KEYsecretwebResend'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_IDwebThe 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_URLsecretwebAny 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.
SSO_ALLOW_PRIVATE_METADATA_HOSTSweb1 lets an IdP metadata URL resolve to a private or loopback address, for an identity provider on your internal network.
Default: 0
SSO_ENFORCE_GRACE_SECONDSwebAfter 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.
Default: 600
SSO_SP_ENTITY_IDwebThe SAML service-provider entity id (audience). Stored per connection when saved, so changing it never breaks a live workspace.
Default: WEB_ORIGIN
Custom share domains and host routing
Reviewer links on a workspace's own hostname, and which hosts count as this deployment.
CF_API_TOKENsecretwebCloudflare for SaaS API token (cloud only). Needs CF_ZONE_ID too.
CF_ZONE_IDwebCloudflare for SaaS zone (cloud only).
DOMAIN_CNAME_TARGETwebWhat customers CNAME their hostname to. Leave unset on self-hosted: verification then accepts any CNAME/A/AAAA record pointing at your ingress.
DOMAIN_LOOKUP_SECRETsecretweb · sandbox · realtimeShared secret for the internal host-lookup route middleware calls for custom domains.
Default: AUTH_SECRET
DOMAIN_TLS_PROVIDERwebHow 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.
Default: caddy
EXTRA_PRIMARY_HOSTSwebComma-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_IPweb · realtimeRead 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.
Default: false
TRUST_PROXY_HOSTwebTrust 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.
Default: false
TRUSTED_PROXY_HOPSweb · realtimeHow 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.
Default: 1
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.
INTEGRATIONS_ALLOW_ANY_WEBHOOK_HOSTweb0 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.
Default: 0
INTEGRATIONS_SECRETsecretwebEncrypts stored webhook URLs and tracker tokens at rest (AES-256-GCM).
Default: AUTH_SECRET
ISSUE_TRACKERS_ENABLEDweb1 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.
Default: 0
JIRA_CLIENT_IDwebJira 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_SECRETsecretwebJira OAuth app secret.
LINEAR_CLIENT_IDwebLinear OAuth app. Both Linear variables must be set to offer Linear. Redirect: <WEB_ORIGIN>/api/integrations/oauth/callback/linear.
LINEAR_CLIENT_SECRETsecretwebLinear OAuth app secret.
LINEAR_WEBHOOK_SECRETsecretwebVerifies 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.
INSTANCE_IDwebA name for this deployment. Only matters for a licence issued bound to one instance; such a key is refused anywhere else.
LICENSE_KEYsecretwebThe 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_FILEwebPath 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_KEYwebTesting 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.
MAX_HTML_BYTESwebLargest single HTML file in a package, whatever the plan's package limit. A bigger page is refused at upload (html_too_large).
Default: 10485760 (10 MiB)
MAX_PACKAGE_BYTES_FREEwebLargest package a Free-plan workspace may upload.
Default: 26214400 (25 MiB)
MAX_PACKAGE_BYTES_PROwebLargest package on paid plans. Raise your ingress body-size limit with it (the chart's web.ingress.maxBodySize).
Default: 104857600 (100 MiB)
MAX_SCREENSHOT_BYTESwebLargest screenshot per image. Each thread's first comment carries two (viewport and element crop).
Default: 4194304 (4 MiB)
MAX_VOICE_BYTESwebLargest voice note, roughly ten minutes of Opus audio.
Default: 10485760 (10 MiB)
Voice transcription
Audio is always stored; these decide whether and by whom it is transcribed.
OPENAI_API_KEYsecretwebFor TRANSCRIBE_PROVIDER=openai only.
TRANSCRIBE_COMMANDwebFor 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_COMMANDwebFor command: run first to convert browser audio, e.g. ffmpeg -nostdin -y -i {input} -ar 16000 -ac 1 {output}.wav.
TRANSCRIBE_OPENAI_MODELwebModel for TRANSCRIBE_PROVIDER=openai.
Default: gpt-4o-mini-transcribe
TRANSCRIBE_PROVIDERwebnone 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).
Default: none
Link previews
Page titles for links pasted into comments.
LINK_PREVIEWSweboff (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.
Default: on
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.
LEGAL_LINKSwebon 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).
Default: on for critlab cloud, off when BILLING_PROVIDER is unset or `none`
LEGAL_PRIVACY_URLwebYour 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_URLwebYour 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.
RETENTION_AUDIT_MONTHSwebHow 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).
Default: 13
RETENTION_DISABLEDweb1 (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.
Default: off
RETENTION_DRY_RUNweb1 (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.
Default: off
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.
SCHEDULERwebon 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.
Default: `on` for a production server (every self-hosted install and critlab cloud); `off` under `next dev`, in tests and on the isolated e2e stack
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.
SENTRY_AUTH_TOKENsecretwebBuild 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_DSNweb · sandbox · realtimeTurns 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_ENVIRONMENTweb · sandbox · realtimeTags every report with an environment (production, staging, …) so alerts can tell them apart. Only used when SENTRY_DSN is set.
Default: `production` when `NODE_ENV=production`, else `development`
SENTRY_ORGwebBuild time only: the Sentry organisation slug the source maps are uploaded to. Used only together with SENTRY_AUTH_TOKEN.
SENTRY_PROJECTwebBuild time only: the Sentry project slug the source maps are uploaded to. Used only together with SENTRY_AUTH_TOKEN.
SENTRY_RELEASEweb · sandbox · realtimeTags 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_URLwebBuild 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.
Default: https://sentry.io
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.
ANALYTICSwebon 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).
Default: `on` when `BILLING_PROVIDER` is a real provider (critlab cloud), `off` when it is unset or `none` (self-hosted)
Process and build
Set by the container images; only change them when running from source.
CRITLAB_SNIPPET_PATHwebPath to the staging snippet's built snippet.js. The web image sets it; only override for an unusual filesystem layout.
NEXT_DIST_DIRwebNext.js build directory. The web image sets .next-build; leave it alone.
Default: .next
NEXT_RUNTIMEwebSet by Next.js itself (nodejs or edge); read only so error reporting starts in the Node runtime. Never set it.
NODE_ENVwebproduction 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.
PORTwebPort web listens on.
Default: 3000
RENDERER_CHROME_PATHrendererRenderer only: the path of Chrome for Testing. The renderer image sets it; only override when running from source.
Default: /opt/chrome/chrome
RENDERER_HOSTrendererRenderer only: the interface its lease API listens on, inside the container's private network. Leave it alone.
Default: 0.0.0.0
RENDERER_PORTrendererRenderer 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.
Default: 4020
Cloud billing (critlab cloud only)
Paddle checkout. A self-hosted install leaves BILLING_PROVIDER unset or none and needs none of the rest.
BILLING_PROVIDERwebnone 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.
Default: none
PADDLE_API_KEYsecretwebCloud only.
PADDLE_CLIENT_TOKENwebCloud only. Sent to the browser for checkout; not secret.
PADDLE_ENVwebCloud only. sandbox or live.
Default: sandbox
PADDLE_PRICE_CREDITS_10webCloud only. Paddle price id.
PADDLE_PRICE_CREDITS_50webCloud only. Paddle price id.
PADDLE_PRICE_PROwebCloud only. Paddle price id.
PADDLE_PRICE_TEAMwebCloud only. Paddle price id.
PADDLE_WEBHOOK_SECRETsecretwebCloud 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.
ANTHROPIC_API_KEYsecretwebReserved. Leave unset.
REVIEW_BATCHwebReserved. Leave unset.
REVIEW_MODELwebReserved. Leave unset.
REVIEW_OPENAI_API_KEYsecretwebReserved. Leave unset.
REVIEW_OPENAI_BASE_URLwebReserved. Leave unset.
REVIEW_PROVIDERwebReserved. Leave unset.
Default: none
REVIEW_UNMETEREDwebReserved. 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.
AUTH_RATE_LIMITweboff disables sign-in rate limiting, but only while WEB_ORIGIN is a loopback origin. Ignored on a real deployment.
CRITLAB_DEV_KEEP_COMPILED_ROUTESweb1 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_STACKwebMarks 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_OVERRIDEwebPoints Linear/Jira API calls at a local fake. Ignored when NODE_ENV=production.
LINK_PREVIEWS_ALLOW_PRIVATE_HOSTSwebExact ip:port pairs a local test fixture listens on. Honoured only while WEB_ORIGIN is loopback.
MAILER_DEV_OUTBOXwebConsole transport also writes each message, sign-in codes included, to this file. Never set in production.
REVIEW_FAKE_COST_MICROSwebTest fixture for a reserved feature.
REVIEW_FAKE_DELAY_MSwebTest fixture for a reserved feature.
REVIEW_FAKE_FINDINGS_FILEwebTest fixture for a reserved feature.
SCHEDULER_TEST_FIRST_DELAY_MSwebReplaces 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_MSwebShortens 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_FILEwebAnswers SSO domain-verification DNS lookups from a JSON file. Honoured only while WEB_ORIGIN is loopback.
TEST_DB_ALLOWwebExtra database names the test guard lets integration tests connect to.