Skip to content

Deploy Kairos to Kubernetes

In this tutorial we will run Kairos on a Kubernetes cluster from the published chart and image, with a database and an identity provider beside it. By the end you will have a deployment answering /healthz and /readyz and serving the interface, and you will have seen what Kairos needs from a cluster and what it brings itself.

It takes about twenty minutes, most of which is waiting for images to pull.

You will need:

  • Docker, running.
  • kind — we will create a throwaway cluster and delete it at the end.
  • kubectl and Helm 3.8 or newer (the chart is published as an OCI artifact, which older Helm cannot pull).

We use kind because this is a lesson, not a production deployment. Everything here works the same on a real cluster; the database and identity provider are the parts you would replace.

Identity is always yours, and so is state by default. The chart can stand an evaluation PostgreSQL up inside the release, but this lesson brings its own so you see the shape you would actually run. So the first two things we install are a throwaway Postgres and a throwaway Dex, pinned to exact versions so this lesson behaves the same every time. Neither is suitable for anything but a tutorial: no persistence, no backups, one hard-coded password.

The Postgres image is pgvector/pgvector:pg16 rather than postgres:16: the same upstream Postgres with the pgvector extension added. Kairos requires it — the extension backs semantic retrieval, and the first migration installs it, so a plain postgres:16 fails to migrate rather than merely doing without the feature. Managed Postgres offers pgvector on RDS, Cloud SQL and Azure, so this is not a reason to stop bringing your own.

Terminal window
kind create cluster --name kairos-tutorial --wait 120s
kubectl create namespace kairos

You should see kind report the cluster ready and namespace/kairos created.

Terminal window
kubectl -n kairos create deployment postgres --image=pgvector/pgvector:pg16 --port=5432
kubectl -n kairos set env deployment/postgres \
POSTGRES_PASSWORD=kairos POSTGRES_USER=kairos POSTGRES_DB=kairos
kubectl -n kairos expose deployment postgres --port=5432
kubectl -n kairos rollout status deployment/postgres --timeout=240s
deployment "postgres" successfully rolled out

Save this as dex.yaml:

apiVersion: v1
kind: ConfigMap
metadata:
name: dex
namespace: kairos
data:
config.yaml: |
issuer: http://dex.kairos.svc.cluster.local:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
oauth2:
skipApprovalScreen: true
staticClients:
- id: kairos-web
name: Kairos Web GUI
public: true
redirectURIs:
- http://localhost:8080/callback
enablePasswordDB: true
staticPasswords:
- email: admin@example.com
# bcrypt of "admin-password"
hash: "$2y$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: admin
userID: "00000000-0000-0000-0000-000000000001"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dex
namespace: kairos
spec:
replicas: 1
selector: { matchLabels: { app: dex } }
template:
metadata: { labels: { app: dex } }
spec:
containers:
- name: dex
image: ghcr.io/dexidp/dex:v2.41.1
args: ["dex", "serve", "/etc/dex/config.yaml"]
ports: [{ containerPort: 5556 }]
volumeMounts:
- { name: config, mountPath: /etc/dex }
volumes:
- name: config
configMap: { name: dex }
---
apiVersion: v1
kind: Service
metadata:
name: dex
namespace: kairos
spec:
selector: { app: dex }
ports: [{ port: 5556, targetPort: 5556 }]

Apply it and wait:

Terminal window
kubectl apply -f dex.yaml
kubectl -n kairos rollout status deployment/dex --timeout=240s
deployment "dex" successfully rolled out

Save this as values.yaml:

database:
url: "postgres://kairos:kairos@postgres.kairos.svc.cluster.local:5432/kairos"
config:
oidc:
issuerUrl: "http://dex.kairos.svc.cluster.local:5556/dex"
audience: "kairos-web"
tenancy:
singleTenant: "demo"

Those three are the minimum. The chart refuses to render without the two OIDC values, which is deliberate — a deployment that cannot say who its users are is not a deployment.

Terminal window
helm install kairos oci://ghcr.io/colliery-io/charts/kairos --version 0.21.0 \
-n kairos -f values.yaml --wait --timeout 6m

Helm pulls the chart from the registry, then the cluster pulls the image:

Pulled: ghcr.io/colliery-io/charts/kairos:0.21.0
NAME: kairos
STATUS: deployed

The container applies its own database migrations on boot, so there is no separate migrate step.

Terminal window
kubectl -n kairos get pods

The 2 Kairos pods should be 1/1 Running, alongside Postgres and Dex. The chart runs 2 replicas by default. They share the background work through the job queue in the database:

NAME READY STATUS RESTARTS AGE
dex-6d556d5f6d-nh6ww 1/1 Running 0 3m
kairos-5785ff87b8-52d9d 1/1 Running 0 1m
kairos-5785ff87b8-7nqvk 1/1 Running 0 1m
postgres-6f59b65747-djxn8 1/1 Running 0 3m
Terminal window
kubectl -n kairos port-forward svc/kairos 8080:80

Leave that running, and in another terminal:

Terminal window
curl http://localhost:8080/healthz
curl http://localhost:8080/readyz
ok
ready
warning: KAIROS_RETENTION_MODE is archive and KAIROS_ARCHIVE_TARGET is not set. The retention pass removes no rows from item_history and activity_log. Set KAIROS_ARCHIVE_TARGET to a directory, or set KAIROS_RETENTION_MODE to discard.

Those two answer different questions, and the difference matters when you deploy for real. /healthz says the process is alive. /readyz says it can reach its database — which is what Kubernetes should gate traffic on.

The warning: line does not stop the traffic. It tells you that the history of this deployment does not get smaller. To change this, see Back up and restore.

Now open http://localhost:8080. Kairos serves the interface from the same process, and you will be sent to Dex to sign in as admin@example.com / admin-password.

The tenant is empty: this is a fresh deployment, not the demo data from Run Kairos locally. An empty Kairos is the honest starting point for a real one.

Terminal window
kind delete cluster --name kairos-tutorial

That removes the cluster and everything in it, including the database.

Kairos is one stateless image. It brought the interface, the API, MCP and SCIM in a single process, and it needed exactly two things from you: somewhere to keep state, and somebody to say who users are — a Postgres, which later versions need pgvector on, and an OIDC issuer.

That is the whole deployment story, and the rest is making those two things production-grade rather than throwaway.