Self-host on Kubernetes (Helm)
Deploy web, runners and an optional Steel pool with the chart in deploy/helm/qaitai.
The chart deploys web (Deployment, Service, optional Ingress), runners, and an optional Steel browser pool. Postgres, Hatchet and S3-compatible storage are external; run Hatchet with its own Helm charts.
Prepare Postgres
Create a database owned by a non-superuser role (for example qaitai), then the qaitai_app role the
app connects as:
psql "postgres://qaitai:...@pg:5432/qaitai" -v app_password="$(openssl rand -hex 24)" -f docker/sql/app-role.sqlThe app role must not be a superuser, have BYPASSRLS or own the tables; otherwise row-level
security is skipped or weakened.
Create the secrets
kubectl create secret generic qaitai-migrate \
--from-literal=DATABASE_MIGRATION_URL='postgres://qaitai:...@pg:5432/qaitai'
kubectl create secret generic qaitai-env \
--from-literal=DATABASE_URL='postgres://qaitai_app:...@pg:5432/qaitai' \
--from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
--from-literal=QAITAI_MASTER_KEY="$(openssl rand -base64 32)" \
--from-literal=QAITAI_INTERNAL_SECRET="$(openssl rand -hex 32)" \
--from-literal=HATCHET_CLIENT_TOKEN='eyJ...' \
--from-literal=ANTHROPIC_API_KEY='sk-ant-...'Every key in qaitai-env is injected with envFrom. Optional keys include STEEL_API_KEY,
AI_GATEWAY_API_KEY, S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY, GITHUB_* and GOOGLE_*.
Install
helm install qaitai deploy/helm/qaitai \
--set existingSecret=qaitai-env --set migrations.existingSecret=qaitai-migrate \
--set url=https://qa.example.com \
--set ingress.enabled=true --set ingress.host=qa.example.com --set ingress.className=nginx \
--set hatchet.hostPort=hatchet-engine.hatchet:7070 \
--set runner.replicaCount=2 --set runner.slots=4Migrations run in an init container of each web pod with the owner URL from
migrations.existingSecret, so app containers never hold owner credentials.
First sign-in
kubectl logs deploy/qaitai-web | grep -A2 "no users yet"Open the one-time link to become the instance admin.
Options
runner.variant=runner-chromium: local browsers in the runner pods (size about 1 vCPU and 1.5 GiB per slot).runner.mode=remotewithrunner.remote.existingSecret=<secret with QAITAI_TOKEN>: remote-mode runners that don't mount the main secret. See Remote runner.- Email:
--set email.smtp.host=smtp.example.com --set email.smtp.user=qa --set email.from='qaitai <qa@example.com>'and the password from a Secret viaemail.smtp.existingSecret. - Pods run as uid 1000 with a read-only root filesystem, all capabilities dropped and no service account token. Runners get a 900-second termination grace period so runs can finish during rollouts.