# Running on Kubernetes

> Installing the Helm chart — database and SECRET_KEY handling, ingress, persistence, probes, security context, resource sizing and uninstalling.

- Updated: 2026-09-25
- Source: https://contextator.com/en/docs/kubernetes/
- Language: en-US
- Author: Muhammet Şafak

---
A Helm chart is the supported way to run Contextator on Kubernetes. Sources for everything on this page:
the chart itself, at `charts/contextator/` in the repository, and its own longer
`charts/contextator/README.md`.

## Installing

```bash
helm repo add contextator https://contextator.github.io/Contextator
helm repo update
helm install contextator contextator/contextator \
  --set database.url="postgres://user:pass@host:5432/contextator" \
  --set-string image.tag="<version>-slim"
```

`database.url` (or `database.existingSecret`, for a Secret you already manage) and `image.tag` are both
required and have no default — the chart fails `helm install`/`helm template` immediately, before
rendering anything, if either is missing. `image.tag` must point at a `-slim` build; the tags without
that suffix are the full image with an embedded PostgreSQL, the wrong topology for this chart. Or
install straight from a checked-out copy of the chart directory with
`helm install contextator ./charts/contextator -f my-values.yaml` — the same two values are still
required, in the values file or on the command line.

## One replica, by design

`replicaCount` is not a supported value — the chart rejects it outright
(`additional properties 'replicaCount' not allowed`), and this is intentional, not a gap: Contextator
runs a single writer against its own indexing queue, and clustering it would need a coordination layer
the product does not have yet (ADR-0078). Scale the pod's own resources instead of its count; see
[Resource sizing](#resource-sizing) below.

## Database

Either give the chart a connection string directly (`database.url`) or point it at a Secret you already
manage (`database.existingSecret`) — the same choice [Configuration](/en/docs/configuration/) describes
for `DATABASE_URL` outside Kubernetes. PostgreSQL itself is not part of this chart; run it separately, or
point at a managed instance.

## SECRET_KEY: generated once, held stable

`SECRET_KEY` encrypts source credentials at rest — git, Notion and Confluence tokens and webhook
secrets — so losing it does not just log everyone out, it makes those stored credentials unrecoverable.
Left unset, the chart generates one on first install and — because it reads the running
cluster's Secret with Helm's `lookup` function before deciding whether to generate a new one — keeps that
same value across every subsequent `helm upgrade`, without you doing anything.

**The `lookup` function only works against a real, reachable cluster.** Rendering the chart offline —
`helm template`, or a GitOps controller reconciling from a rendered manifest without a live `lookup` — sees
no existing Secret every time and would generate a new key on every render, which is precisely the value
you cannot have change under a running instance. In that setup, generate the key yourself once and hand it
to the chart as an existing Secret instead of letting it generate one:

```bash
kubectl create secret generic contextator-secret-key \
  --from-literal=SECRET_KEY=$(openssl rand -hex 32)
```

```yaml
secretKey:
  existingSecret: contextator-secret-key
  existingSecretKey: SECRET_KEY
```

**Rotating it** is a two-step move, not a value swap. First, save the current value under a new name —
`contextator-secret-key` is about to be overwritten with the new key, so copy the old one out before
generating anything:

```bash
kubectl -n <namespace> create secret generic contextator-secret-key-previous \
  --from-literal=SECRET_KEY="$(kubectl -n <namespace> get secret contextator-secret-key -o jsonpath='{.data.SECRET_KEY}' | base64 -d)"
```

Then generate the new key and write it into `contextator-secret-key` in place (`kubectl create` alone
would fail on a Secret that already exists):

```bash
kubectl -n <namespace> create secret generic contextator-secret-key \
  --from-literal=SECRET_KEY="$(openssl rand -hex 32)" \
  --dry-run=client -o yaml | kubectl -n <namespace> apply -f -
```

With the new key pinned the same way as above (`secretKey.existingSecret`/`secretKey.existingSecretKey`),
give the process the old one as `SECRET_KEY_PREVIOUS` through `envSecret`, whose entries are always
`NAME: {secretName, secretKey}`:

```yaml
envSecret:
  SECRET_KEY_PREVIOUS:
    secretName: contextator-secret-key-previous
    secretKey: SECRET_KEY
```

With both present and the Pod restarted, find the Deployment name (`<release>-contextator`, unless the
release name already contains `contextator` or `fullnameOverride` is set) and run the rotation inside it:

```sh
kubectl -n <namespace> get deploy -l app.kubernetes.io/instance=<release>
kubectl -n <namespace> exec deploy/<deployment-name> -- npm run rotate-secret
```

It decrypts what the old key encrypted and re-encrypts it under the new one. Once it reports nothing
left to convert, remove `SECRET_KEY_PREVIOUS` (the `envSecret` entry) and let the Pod restart again — that
removal is what retires the old key. If the key is gone entirely and there is no
`SECRET_KEY_PREVIOUS` to recover with, source credentials stored under it cannot be decrypted — re-adding
those sources is the only way back.

## Ingress and the reverse-proxy triple

An Ingress in front of the chart is a reverse proxy like any other, and the same triple from
[Configuration](/en/docs/configuration/#running-behind-a-reverse-proxy) applies: `config.trustProxy`,
`config.publicBaseUrl` and `env.AUTH_COOKIE_SECURE` move together, or the per-IP sign-in limit, MCP
connector authorization and the session cookie each break silently in their own way.

**A hop count (`2`, `3`, …) is refused; `1`/`true` is accepted and means *trust every hop*.** That is
the same guardrail [Configuration](/en/docs/configuration/#running-behind-a-reverse-proxy) describes
outside Kubernetes: the chart passes `config.trustProxy` through unvalidated, so `"1"` trusts whoever
wrote the `X-Forwarded-For` header, every hop of it — safe only behind a NetworkPolicy that lets nothing
but the ingress controller reach this Pod's port. Name the proxy instead, the narrowest range that
actually covers it:

```yaml
config:
  publicBaseUrl: "https://contextator.example.com"
  trustProxy: "<ingress-controller-pod-ip-or-cidr>"
env:
  AUTH_COOKIE_SECURE: "1"
```

The value is matched against every hop, not just the socket's peer, so pasting the whole pod CIDR trusts
every other pod that can reach this Service too — safe only behind a NetworkPolicy that lets nothing but
the ingress controller reach this pod's port.

## Persistence

Two `ReadWriteOnce` PersistentVolumeClaims: `/data` (the database-adjacent working files) and
`/app/.cache/models` (the downloaded embedding model, so a pod restart does not re-download it). `/data`
is verified, on a live cluster, to survive a pod restart intact.

## Probes

Liveness is a bare TCP check on the port — enough to catch a wedged process without depending on the
database. Readiness and startup are both a real `GET /api/health` call, so a pod is not marked ready
until the application can actually answer, not merely accept a connection. Verified on a live cluster
against a database outage: the pod reports `READY 0/1` for the duration, takes **zero** restarts because
liveness never depended on the database, and recovers to ready automatically the moment the database
comes back — no manual pod restart needed.

## Security context

The container must start as **uid 0** — not optional, and a hardened `securityContext` that forbids it
fails at startup with *"the container must start as root"*. This is a brief, structural requirement, not
a lingering one: the entrypoint uses that first moment as root to fix ownership on the two mounted
volumes above (a fresh PVC is `root`-owned by default), then immediately hands off to an unprivileged
`node` user via `gosu` before any application code runs. The process that actually serves traffic never
runs as root.

The image also carries no `wget` or `curl` — its own healthcheck and chart tests use `node -e` with
Node's built-in `fetch()` instead, confirmed by a real `helm test` run rather than assumed from the
Dockerfile.

## Resource sizing

Numbers below are measured against a running instance, not estimated:

| State | CPU | Memory |
|-------|-----|--------|
| Idle | low | ~770–820 MiB RSS |
| Indexing burst | ~10 cores for 2–3 seconds | — |
| Cold start | — | ready in ~72.4s |

Set `resources.requests`/`resources.limits` with headroom over the idle figure and enough burst capacity
for indexing spikes; a tight CPU limit during a burst slows a single indexing run rather than affecting
other pods, since there is only ever the one replica.

## Uninstalling

`helm uninstall` leaves the two PersistentVolumeClaims behind by default — they carry a
`helm.sh/resource-policy: keep` annotation, so indexed data and the cached model survive an accidental
uninstall. The `SECRET_KEY` Secret carries no such policy and **is** deleted with the release; if you
plan to reinstall against the same data, save the key first (see
[SECRET_KEY: generated once, held stable](#secret_key-generated-once-held-stable) above) or you will not
be able to decrypt the source credentials the surviving PVCs still reference.

## Strict values schema

The chart validates its values against a strict JSON Schema (ADR-0092) — an unrecognized key fails
`helm install`/`helm upgrade` immediately rather than being silently ignored. Two keys worth knowing by
name because they are easy to reach for under the wrong path: `config.confluenceAllowedHosts` and
`config.mcpStructuredOutput` are both typed, top-level `config.*` keys, not freeform values nested
somewhere else.

## Publishing

Chart releases follow the repository's own release workflow and are published to a `gh-pages`-hosted Helm
repository, versioned independently of the application's own version — see the chart's own
`charts/contextator/README.md` in the repository for the exact release and versioning rules.
