Skip to content

Self-hosting

Production install with Helm

Install critlab on Kubernetes with the Helm chart: images, secrets, external services and the values that matter.

The Helm chart in charts/critlab deploys web, sandbox and realtime, migrates the database from an init container on every install and upgrade, and can bundle Postgres, Redis and SeaweedFS or point at managed ones. Read Requirements and sizing first.

1. Build and push the images

Build from the repository root; each build needs the whole monorepo as its context.

bash
docker build -f infra/docker/web.Dockerfile      -t registry.example.com/critlab/web:0.2.0      .
docker build -f infra/docker/sandbox.Dockerfile  -t registry.example.com/critlab/sandbox:0.2.0  .
docker build -f infra/docker/realtime.Dockerfile -t registry.example.com/critlab/realtime:0.2.0 .
docker build -f infra/docker/migrate.Dockerfile  -t registry.example.com/critlab/migrate:0.2.0  .
docker push registry.example.com/critlab/web:0.2.0
docker push registry.example.com/critlab/sandbox:0.2.0
docker push registry.example.com/critlab/realtime:0.2.0
docker push registry.example.com/critlab/migrate:0.2.0

web.Dockerfile needs no build argument. The one that changes what a self-hosted install shows is LEGAL_LINKS (empty by default, which is right for a self-hosted install): it decides whether the footer of the prerendered documentation pages links to critlab's own Terms and Privacy pages; see LEGAL_LINKS in the configuration reference.

For local voice transcription, build infra/docker/web-whisper.Dockerfile as well and use it instead of web (see Air-gapped installs).

2. Create your secrets

The chart can generate secrets on first install and keeps them across upgrades, but for an install you intend to keep, create the Secret yourself and point the chart at it:

bash
kubectl create namespace critlab
kubectl create secret generic critlab-secret -n critlab \
  --from-literal=AUTH_SECRET="$(openssl rand -base64 32)" \
  --from-literal=SANDBOX_TOKEN_SECRET="$(openssl rand -base64 48)" \
  --from-literal=INTEGRATIONS_SECRET="$(openssl rand -base64 32)" \
  --from-literal=DATABASE_URL="postgres://critlab:$(openssl rand -hex 16)@critlab-postgres:5432/critlab" \
  --from-literal=REDIS_URL="redis://critlab-redis:6379" \
  --from-literal=S3_ACCESS_KEY="critlab" \
  --from-literal=S3_SECRET_KEY="$(openssl rand -base64 32)"

secrets.existingSecret must carry exactly these seven keys. SMTP_URL goes in through secrets.smtpUrl or web.extraEnv; see Email.

3. Write a values file

yaml
image:
  registry: registry.example.com
  repositoryPrefix: critlab

ingress:
  appHost: critlab.example.com
  sandboxHost: proto.example.com      # used only in path mode; harmless otherwise
  controller: nginx                   # nginx | traefik | haproxy | none
  tls:
    mode: provided                    # provided | cert-manager | none
    appSecretName: critlab-app-tls
    sandboxSecretName: critlab-sandbox-tls   # the wildcard certificate

sandbox:
  urlMode: host
  zone: s.example.com                 # bare zone, no "*." and no scheme
  image:
    tag: "0.2.0"

secrets:
  existingSecret: critlab-secret

config:
  mailFrom: "critlab <critlab@example.com>"

web:
  image:
    tag: "0.2.0"
  extraEnv:
    - name: DATABASE_IDLE_TIMEOUT
      value: "300"

realtime:
  image:
    tag: "0.2.0"

migrate:
  image:
    tag: "0.2.0"

The values that matter most:

ValueDefaultWhat it controls
ingress.appHost(required)The app hostname. The chart refuses to render without it.
sandbox.urlModehosthost: one origin per version under sandbox.zone, wildcard certificate required. path: one origin at ingress.sandboxHost.
sandbox.zone(required in host mode)The sandbox zone. Must differ from appHost; the chart fails fast if not.
ingress.realtimeHostunsetUnset: realtime is served at wss://<appHost>/ws. Set: its own hostname and certificate.
ingress.controllernginxWhich controller-specific annotations to render for upload size and timeouts.
web.ingress.maxBodySize110mUpload body limit on the web Ingress. Must be at least 1.05 × the largest package limit; the chart checks.
ingress.tls.modeprovidedBring your own Secrets, let cert-manager issue them, or none when TLS ends upstream.
postgresql.enabled / redis.enabled / seaweedfs.enabledtrueBundle each dependency, or set false and use externalDatabase.url, externalRedis.url, externalS3.*.
license.existingSecretunsetA Secret holding LICENSE_KEY. See Licence.
config.transcribeProvidernoneVoice transcription; web.image.variant: whisper switches to the bundled local model.
config.trustProxyHosttrueTrust X-Forwarded-Host from the ingress.
config.trustedProxyHops1How many reverse proxies of yours sit in front of web, counted from the right of X-Forwarded-For; the client is the entry that many places from the right. 1 is the ingress alone (ingress-nginx's defaults replace the header with the address it saw, which is what 1 expects). 2 is a CDN in front of it, and is only correct when the ingress appends to the CDN's header instead of replacing it: set use-forwarded-headers: "true" and compute-full-forwarded-for: "true" in ingress-nginx's ConfigMap. Otherwise leave it at 1. 0 trusts no header. With iap.enabled, web is given one more than this: see config.webTrustedProxyHops.
config.webTrustedProxyHopsemptyTRUSTED_PROXY_HOPS for web alone (0 to 16). Empty derives it: config.trustedProxyHops, plus 1 when iap.enabled (0 stays 0). oauth2-proxy appends the ingress pod's address to X-Forwarded-For, so without the extra hop every visitor would resolve to that pod and share one bucket in each per-address limit (email-code sends, sign-in attempts, the password unlock). Realtime keeps config.trustedProxyHops, because its /ws Ingress does not go through the proxy. Set this only when the real chain differs: an oauth2-proxy that does not append (extra iap.extraArgs), or one more proxy of yours between it and web. The chart refuses a number outside 0 to 16.
config.trustCfConnectingIpfalseRead Cloudflare's CF-Connecting-IP first. Only when the origin accepts traffic from Cloudflare alone.
networkPolicy.enabledfalsePod-level network policies. Needs a policy-enforcing CNI.
iap.enabledfalseAn oauth2-proxy in front of the app host only, as an extra outer login.

charts/critlab/README.md documents every value.

4. Install

bash
helm install critlab charts/critlab --namespace critlab -f values.yaml

There is no migration hook — every web pod migrates itself from an init container before it serves, so on a first install the pods show Init:0/1 until the database is up and migrated; this is expected, not a stall (see Migrations below). The chart's notes print the app, sandbox and realtime URLs and the commands to check the migration and bucket-init jobs.

Then open https://critlab.example.com/signup, sign up with your address and verify the code. The first account creates the first workspace; invite the rest of your team from Settings → Members.

External Postgres, Redis and S3

yaml
postgresql:
  enabled: false
redis:
  enabled: false
seaweedfs:
  enabled: false
externalS3:
  endpoint: https://s3.eu-central-1.amazonaws.com
  region: eu-central-1
  bucket: critlab
  forcePathStyle: false

Put the connection strings in your Secret (DATABASE_URL, REDIS_URL, S3_ACCESS_KEY, S3_SECRET_KEY) rather than in externalDatabase.url or externalRedis.url, so they stay out of Helm's release history. Create the bucket before installing. With a managed database, set DATABASE_IDLE_TIMEOUT=300 on web as shown above.

Realtime and WebSockets

With ingress.realtimeHost unset, the chart adds an Ingress for /ws on the app host and derives REALTIME_URL as wss://<appHost>/ws. ingress.realtimeAnnotations sets one-hour read and send timeouts for ingress-nginx and is applied only to the realtime Ingress. Traefik upgrades WebSockets by itself (set the annotations to {}); HAProxy Ingress uses haproxy-ingress.github.io/timeout-tunnel.

Run realtime with more than one replica only with Redis configured: without it, presence lives in one process's memory.

Upload size

Out of the box, ingress-nginx rejects bodies over 1 MB with a plain 413, before critlab sees the request. ingress.controller plus web.ingress.maxBodySize render the right setting for ingress-nginx (proxy-body-size and 300-second proxy timeouts), Traefik (a buffering Middleware when ingress.traefik.createMiddleware=true) or HAProxy Ingress. For any other controller, set ingress.controller: none and configure the limit yourself.

Identity-aware proxy (optional)

iap.enabled=true puts oauth2-proxy in front of the app host, so only people in your corporate identity provider reach critlab's own sign-in page. It never sits in front of the sandbox or realtime: reviewers on link or password access are often outside your IdP, and the sandbox must stay cookieless. iap.skipAuthRoutes lists the paths that must stay reachable without it — reviewer surfaces, sign-in, webhooks, SCIM and /api/health. critlab's own sign-in stays in place behind the proxy. oauth2-proxy appends the ingress pod's address to X-Forwarded-For, so the chart gives web one more trusted proxy hop than config.trustedProxyHops (derived, and overridable with config.webTrustedProxyHops). Leave config.trustedProxyHops at what the path in front of the ingress needs and do not count the proxy yourself. Confirm it once on your cluster: sign in from two different networks and check that the address recorded in the audit log (audit_events.ip) differs.

The renderer (optional, off by default)

renderer.enabled=true adds the headless Chrome that renders thumbnails, the HTML canvas and AI review runs. Without it nothing is rendered: cards show their placeholder and an HTML canvas waits (uploads are never blocked). The release web image carries no Chrome, so while renderer.enabled is false the chart sets HEADLESS_RENDERER=off for web (in the ConfigMap), and web does not try to start a browser; override it with web.extraEnv if your web image has its own. CANVAS_CRAWL is not set by the chart. Treat it as untested: the chart renders, passes kubeconform -strict for Kubernetes 1.33.0 and is held to its rules by 82 tests, but it has never been applied to a cluster. No kubelet has loaded its seccomp profile, no CNI has enforced its NetworkPolicies, and the renderer image has only run as linux/arm64.

It adds a renderer Deployment (exactly one replica, written in the template: a lease and its WebSocket must land on the same pod), a Caddy gateway built from the same Caddyfile the Compose file uses, two NetworkPolicies, and HEADLESS_RENDERER_URL, HEADLESS_HARNESS_ORIGIN and RENDERER_SECRET. The renderer pod has no service account token, no envFrom and no app secret: of the chart's Secret it receives one key. Build the image like the others: docker build -f infra/docker/renderer.Dockerfile -t registry.example.com/critlab/renderer:0.2.0 . (release it before a web release that raises the browser floor).

  1. Put the seccomp profile on every node that can run it. A chart cannot place a file on a node: copy charts/critlab/files/seccomp-chrome.amd64.oci.json or seccomp-chrome.arm64.oci.json, whichever the node is, to /var/lib/kubelet/seccomp/critlab/seccomp-chrome.json, by node provisioning or a DaemonSet (the Security Profiles Operator is another route, unverified here; the chart generates no resources for it). Do not use infra/renderer/seccomp-chrome.json: it is Docker's format, and containerd drops its includes, excludes and archMap without a word, which would allow clone, unshare, setns, mount and bpf with any arguments. The two chart files are that profile resolved for a container with no capability; never pair them with an added capability. Each names the node's own architecture only and allows neither socketcall nor ipc, so a call from a 32-bit ABI is killed (the image holds no 32-bit code). Chrome also needs a kernel that allows unprivileged user namespaces and no AppArmor or SELinux policy that refuses them to the container. A pod stuck in CreateContainerError means the profile is not on its node. There is no other mode: the pod always runs under this profile with every capability dropped, and a values file that still sets renderer.seccomp.mode (the removed userns mode) is refused. renderer.hostUsers: false is an optional extra that gives the pod its own user namespace on top of the profile; it has never run, and needs Linux 6.3 or newer, runc 1.2 or crun 1.9 and containerd 2.0 or CRI-O 1.25.
  2. Create the gateway certificate Secret. The chart generates none. infra/compose/scripts/gateway-cert.sh <sandbox.zone> <dir> makes a self-signed *.<zone> certificate and prints its pin; then kubectl create secret tls <name> --cert=<dir>/zone.crt --key=<dir>/zone.key, and set renderer.gateway.tls.secretName and renderer.trustedSpki. With a public-CA certificate for *.<zone>, set renderer.publicCertificate: true instead.
  3. Set the values: sandbox.urlMode: host, sandbox.scheme: https, a bare sandbox.zone, networkPolicy.enabled: true, the Secret name and the pin. The chart stops with a message naming the value if any of these is wrong, if both or neither trust setting is given, or if RENDERER_SECRET is shorter than 32 characters.
  4. Check it: kubectl logs deploy/<release>-renderer must say probe: sandbox on, and the pod must become Ready. There is no liveness probe on purpose: a host that cannot sandbox Chrome keeps /health at 503 with one log line naming the reasons, and a restart loop would hide it.

The pin breaks at the next key change. cert-manager's Certificate defaults to rotationPolicy: Always in recent versions, so each renewal makes a new key. A broken pin gives blank renders, which are recorded as final for that version. Use a certificate whose key you control, and on a rotation add the new pin next to the old one first. A public-CA certificate needs no pin.

It is weaker than the Compose file in one place. Compose keeps the renderer on a network with no route out. Kubernetes has none; the NetworkPolicies are the wall, and they only work on a CNI that enforces them. Without one, the lease endpoint is reachable from the whole cluster and only RENDERER_SECRET protects it, so the chart refuses to render without networkPolicy.enabled unless renderer.allowWithoutNetworkPolicy is set. The cluster DNS also forwards to the internet, so the renderer's Chrome is told to resolve nothing but the gateway, and both policies open port 53 only to the peers in renderer.networkPolicy.dns (by default the k8s-app=kube-dns pods in kube-system). If your cluster's DNS pods are labelled differently, set that value: with a wrong one no name resolves and every render fails. NodeLocal DNSCache needs an ipBlock peer for its link-local address (commonly 169.254.20.10/32). What this does not close: the cluster DNS forwards upstream, so code that has escaped Chrome could still tunnel data through the allowed queries; only a resolver that answers just the gateway would stop that, and none is built. The gateway runs as the image's root with every capability dropped but NET_BIND_SERVICE: it passes the Pod Security baseline level and a namespace that enforces restricted refuses it. The renderer passes restricted.

Migrations

There is no migration hook. Each web pod runs a migrate init container that waits for Postgres, takes a lock and applies pending migrations. Sandbox pods wait in wait-for-schema until that is done. On a first install, pods show Init:0/1 until the database is up and migrated. The same holds for external Postgres, helm install --wait, Argo CD and Flux. Follow it with kubectl logs deploy/<release>-web -c migrate. On upgrade, the first new web pod migrates before it serves while the old pods keep serving.

Do not give critlab a superuser. Because web migrates from its own init container, web's DATABASE_URL needs a role that owns the schema, but it does not need a superuser: every migration runs as a plain role that owns the database (pg_trgm, the one extension, is a trusted extension). A superuser can run programs and read files on the database server (COPY ... TO PROGRAM, pg_read_file), so an injection anywhere in an app that connects as one reaches the whole host. For an external database, create a dedicated role and database instead of using postgres:

sql
CREATE ROLE critlab LOGIN PASSWORD '<generated>' NOSUPERUSER NOCREATEDB NOCREATEROLE;
CREATE DATABASE critlab OWNER critlab;

The production Compose file goes one step further and separates the two: migrate connects as the owner and web and sandbox as critlab_app, a role with only SELECT, INSERT, UPDATE and DELETE on critlab's tables (infra/postgres/bin/critlab-app-role in the repository creates it). The chart does not do that split today.

Database and internal lookup for the sandbox

The sandbox reads each prototype version's file list from Postgres (read-only), so the chart gives it the same DATABASE_URL as web and, with networkPolicy.enabled, lets it reach Postgres on 5432. For an external database, set networkPolicy.externalDatabase.cidr (and .port) to your database's address. For a narrower role, create a read-only Postgres user with SELECT on the public schema and on drizzle.__drizzle_migrations, and point sandbox.database.existingSecret at a Secret holding its URL.

Prototypes stored in a workspace's own bucket are fetched through web's internal lookup at INTERNAL_ORIGIN (the in-cluster web Service) using INTERNAL_LOOKUP_SECRET, which the chart generates. With secrets.existingSecret, add an INTERNAL_LOOKUP_SECRET key, or those prototypes return 404. If the bundled SeaweedFS is on and workspaces use their own buckets, add an egress rule to networkPolicy.sandbox.extraEgress.