# Production install with Helm

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

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

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](/docs/self-hosting/requirements) 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](/docs/self-hosting/configuration).

For local voice transcription, build `infra/docker/web-whisper.Dockerfile` as well and use it instead of `web` (see [Air-gapped installs](/docs/self-hosting/air-gapped)).

## 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](/docs/self-hosting/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:

| Value | Default | What it controls |
| --- | --- | --- |
| `ingress.appHost` | *(required)* | The app hostname. The chart refuses to render without it. |
| `sandbox.urlMode` | `host` | `host`: 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.realtimeHost` | unset | Unset: realtime is served at `wss://<appHost>/ws`. Set: its own hostname and certificate. |
| `ingress.controller` | `nginx` | Which controller-specific annotations to render for upload size and timeouts. |
| `web.ingress.maxBodySize` | `110m` | Upload body limit on the web Ingress. Must be at least 1.05 × the largest package limit; the chart checks. |
| `ingress.tls.mode` | `provided` | Bring your own Secrets, let cert-manager issue them, or `none` when TLS ends upstream. |
| `postgresql.enabled` / `redis.enabled` / `seaweedfs.enabled` | `true` | Bundle each dependency, or set `false` and use `externalDatabase.url`, `externalRedis.url`, `externalS3.*`. |
| `license.existingSecret` | unset | A Secret holding `LICENSE_KEY`. See [Licence](/docs/self-hosting/license). |
| `config.transcribeProvider` | `none` | Voice transcription; `web.image.variant: whisper` switches to the bundled local model. |
| `config.trustProxyHost` | `true` | Trust `X-Forwarded-Host` from the ingress. |
| `config.trustedProxyHops` | `1` | How 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.webTrustedProxyHops` | empty | `TRUSTED_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.trustCfConnectingIp` | `false` | Read Cloudflare's `CF-Connecting-IP` first. Only when the origin accepts traffic from Cloudflare alone. |
| `networkPolicy.enabled` | `false` | Pod-level network policies. Needs a policy-enforcing CNI. |
| `iap.enabled` | `false` | An 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](#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`.

> **Open questions**
>
> - **Published images and chart.** Images are built by CI but not pushed anywhere, and the chart is installed from the repository. A registry and a published chart are not decided yet.
> - **Custom share domains on Kubernetes.** The chart can route unknown hosts to web (`ingress.customDomainsCatchAll.enabled`), but nothing issues their certificates on demand yet; you bring a certificate per domain. See [Domains and TLS](/docs/self-hosting/domains-tls).
