Contextator
ENTR

Operating

Running on Kubernetes

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

Updated:

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

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 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 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:

kubectl create secret generic contextator-secret-key \
  --from-literal=SECRET_KEY=$(openssl rand -hex 32)
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:

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):

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}:

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:

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 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 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:

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 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.

Arrow keys to move, Enter to open.