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.