# Air-gapped installs

> Mirror the images, turn off every outbound call, and transcribe voice notes locally.

Source: https://critlab.app/docs/self-hosting/air-gapped

critlab runs with no internet access at all. Nothing calls home — not at install, not for licence checks, not for telemetry — and every outbound feature can be switched off. This page lists what to mirror and what to set.

## 1. Mirror the images

Build critlab's images on a connected machine (see [Production install with Helm](/docs/self-hosting/helm)), export them, and load them into your internal registry:

```bash
docker save \
  registry.example.com/critlab/web:0.2.0 \
  registry.example.com/critlab/sandbox:0.2.0 \
  registry.example.com/critlab/realtime:0.2.0 \
  registry.example.com/critlab/migrate:0.2.0 \
  -o critlab-images-0.2.0.tar
```

```bash
docker load -i critlab-images-0.2.0.tar
docker push registry.internal.example/critlab/web:0.2.0
```

The full list, depending on which dependencies you bundle:

| Image | Needed when |
| --- | --- |
| `critlab/web` (or `critlab/web-whisper`) | always |
| `critlab/sandbox`, `critlab/realtime`, `critlab/migrate` | always |
| `postgres:16-alpine` | `postgresql.enabled` |
| `redis:7-alpine` | `redis.enabled` |
| `chrislusf/seaweedfs:4.47` | `seaweedfs.enabled` |
| `amazon/aws-cli:2.32.17` | `seaweedfs.enabled` (creates the bucket once) |
| `quay.io/oauth2-proxy/oauth2-proxy:v7.15.4` | `iap.enabled` |
| `critlab/renderer` | `renderer.enabled` (critlab's own image; build it from `infra/docker/renderer.Dockerfile`) |
| `caddy:2.11.7-alpine@sha256:e70b164d…` | `renderer.enabled` (the renderer's gateway; keep the digest when you mirror it, `images.registry` prefixes it) |

Generate the exact list for your values file with:

```bash
helm template critlab charts/critlab -f values.yaml | grep 'image:' | sort -u
```

Point the chart at your mirror: `image.registry` and `image.repositoryPrefix` retarget critlab's own images, `images.registry` prefixes every third-party image, and `global.imagePullSecrets` supplies the pull credential to every pod.

## 2. Turn off outbound calls

| Feature | Air-gapped setting |
| --- | --- |
| Page titles for links pasted into comments | `LINK_PREVIEWS=off`. Badges then show domain and path; links to your own instance still show the prototype name. |
| Email | `SMTP_URL` pointing at your internal relay. Never set `RESEND_API_KEY`. |
| Voice transcription | Local whisper.cpp (below), or `TRANSCRIBE_PROVIDER=none`. Never `openai`. |
| Google / Microsoft sign-in | Leave the `GOOGLE_*` and `MICROSOFT_*` variables unset; use SAML against your internal IdP. |
| SAML metadata URL | Paste the IdP's metadata XML instead, or set `SSO_ALLOW_PRIVATE_METADATA_HOSTS=1` if the IdP serves it on your network. |
| Integrations | Leave `ISSUE_TRACKERS_ENABLED`, `LINEAR_*` and `JIRA_*` unset. Slack-compatible webhooks to an internal Mattermost or Rocket.Chat need `INTEGRATIONS_ALLOW_ANY_WEBHOOK_HOST=1`. |
| Billing | `BILLING_PROVIDER` unset or `none` (the default). The app's CSP then never mentions the payment provider. |
| Error reporting | `SENTRY_DSN` unset (the default): nothing is initialised and nothing is sent. To collect reports inside the air gap, point it at an internal GlitchTip. See [Monitoring](/docs/self-hosting/monitoring#error-reporting). |
| Product analytics | `ANALYTICS` unset (the default on a self-hosted install, where `BILLING_PROVIDER` is `none`): nothing is recorded. If you turn it on it is first-party only: rows in your own Postgres, nothing sent anywhere. See [Monitoring](/docs/self-hosting/monitoring#product-analytics). |
| Framework telemetry | Already off: the images set `NEXT_TELEMETRY_DISABLED=1`, and the sign-in library's own usage telemetry is switched off in code. |
| External images and fonts in prototypes | Off by default per prototype; leave it off. |

The licence key is verified offline against a public key compiled into the build. See [Licence](/docs/self-hosting/license).

## 3. Transcribe voice notes locally

The `web-whisper` image is the web image plus ffmpeg, a statically built whisper.cpp and a baked-in `ggml-base.en` model, checksum-verified at build time. It makes no network calls at runtime.

```bash
docker build -f infra/docker/web-whisper.Dockerfile -t registry.example.com/critlab/web-whisper:0.2.0 .
```

```yaml
web:
  image:
    variant: whisper
```

That switches the chart to `TRANSCRIBE_PROVIDER=command` with a matching `TRANSCRIBE_COMMAND` and `TRANSCRIBE_CONVERT_COMMAND`. For a larger model, rebuild with the `WHISPER_MODEL` and `WHISPER_MODEL_SHA256` build arguments and set `web.transcription.model` to match. Without transcription, voice notes are still stored and playable, just without a transcript.

## 4. The sandbox certificate

A wildcard certificate is the hard part without public ACME. Issue `*.s.example.com` from your internal CA (see [Domains and TLS](/docs/self-hosting/domains-tls)) and make sure your users' browsers trust that CA. If no wildcard can be issued at all, path mode works with the limitation described there.

## 5. The CLI and agents

Install `@critlab/cli` from your internal npm mirror (or vendor it) rather than `npx`-ing it from the public registry, and point it at your instance with `CRITLAB_API`.

> **Open questions**
>
> - **SAML fallback bridge.** SAML runs in-process and needs no extra service. If a customer's IdP ever needs the Ory Polis (formerly BoxyHQ SAML Jackson) bridge, that image must be mirrored too and run with `BOXYHQ_NO_TELEMETRY=1`; it is not part of the chart today.
> - **Published image tarballs.** There is no signed, versioned image bundle for offline transfer yet; you build and save the images yourself.
