arizuko runs fine on a single Linux host under Docker Compose. The main
reason to move to Kubernetes is secret management: K8s Secrets live in
etcd, which cloud providers encrypt at rest by default, and they
integrate natively with Vault, AWS Secrets Manager, and GCP Secret
Manager via the External Secrets Operator. No plaintext
.env files on disk.
This guide covers a single-pod deployment — all daemons in one pod
sharing a PersistentVolumeClaim. That’s the right starting point
because gated and the channel adapters communicate over
a Unix socket, and SQLite WAL has a single-writer constraint anyway.
Split into separate Deployments only once you have a concrete reason.
gated spawns per-group agent containers via the
Docker socket. In Kubernetes, mounting docker.sock from
a node is discouraged and unreliable. A
CONTAINER_BACKEND=kubernetes mode is specced but not yet
shipped. Until it lands, the options are: (a) Docker-in-Docker sidecar
(easiest to get running, not recommended for production), or (b) KIND
inside the pod for dev/test. Treat the agent-spawn path as the one
rough edge of a K8s deployment.
arizuko needs a handful of long-lived secrets: AUTH_SECRET
(also the encryption key for the secrets table), CHANNEL_SECRET,
and one or more adapter tokens
(TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN, etc.).
On a single host these live in a mode-0600 .env file
— fine, but manual to rotate and invisible to audit logs.
In Kubernetes the same values become K8s Secret objects. Cloud-managed clusters encrypt etcd at rest by default; for tighter guarantees you can add envelope encryption backed by AWS KMS, GCP KMS, or HashiCorp Vault. IRSA (AWS) and Workload Identity (GCP) let pods fetch secrets without any long-lived credential at all — the node’s identity is the proof. The External Secrets Operator then syncs from Vault or Secrets Manager into K8s Secrets automatically on rotation.
Rolling deploys are also cleaner: update the Secret, trigger a rollout, old pods drain before new ones start.
The Docker Compose stack maps to Kubernetes like this:
| compose service | K8s resource | notes |
|---|---|---|
gated |
container in Pod | owns SQLite, IPC socket; must be first to start |
timed, onbod, dashd |
containers in same Pod | connect to gated over loopback or shared unix socket |
proxyd, webd |
containers in same Pod | proxyd binds :443/:80; expose via LoadBalancer or Ingress |
adapter (teled, slakd, …) |
containers in same Pod | one per enabled platform; add to spec as needed |
data dir /srv/data/arizuko_<name>/ |
PersistentVolumeClaim (RWO) | mounted at the same path in every container |
.env non-secret vars |
ConfigMap → envFrom |
ASSISTANT_NAME, CONTAINER_IMAGE, WEB_HOST, … |
.env secret vars |
Secret → envFrom |
AUTH_SECRET, CHANNEL_SECRET, adapter tokens |
On a bare host, HOST_APP_DIR points to the checkout of
the arizuko repo on the host filesystem. The agent container bind-mounts
it to read skills from ant/skills/. In Kubernetes
there’s no host checkout to bind-mount — skills are baked
into the container image at build time.
Set HOST_APP_DIR to the path inside the container where
skills land, typically /workspace or wherever the
Dockerfile puts them. The agent is the container; the bind-mount
becomes a no-op path reference to the image’s own filesystem.
Check your arizuko-ant Dockerfile for the exact path.
HOST_DATA_DIR should still point to the PVC mount path
(/srv/data/arizuko_<name>) — that part is
unchanged.
One RWO volume holds the entire data dir. Size it generously; SQLite and media attachments grow over time.
# pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: arizuko-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
Non-secret config. Values here are visible in kubectl get configmap.
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: arizuko-config
data:
INSTANCE_NAME: "demo"
ASSISTANT_NAME: "Ari"
WEB_HOST: "https://demo.example.com"
CONTAINER_IMAGE: "arizuko-ant:latest"
HOST_DATA_DIR: "/srv/data/arizuko_demo"
HOST_APP_DIR: "/workspace"
LISTEN_ADDR: ":8080"
LOG_LEVEL: "info"
Sensitive vars. If you use the External Secrets Operator, this object is generated automatically from Vault or Secrets Manager — see secret management below. Otherwise create it manually:
# secret.yaml (base64-encode every value)
apiVersion: v1
kind: Secret
metadata:
name: arizuko-secrets
type: Opaque
stringData:
AUTH_SECRET: "change-me-32-char-random-string"
CHANNEL_SECRET: "change-me-another-32-char-string"
TELEGRAM_BOT_TOKEN: "123456:ABC..." # if using teled
SLACK_BOT_TOKEN: "xoxb-..." # if using slakd
SLACK_SIGNING_SECRET: "..." # if using slakd
kubectl apply -f secret.yaml once, then delete the
local file. Better: use External Secrets or Sealed Secrets so the repo
never holds plaintext.
A single Deployment with one Pod. All daemon containers share the PVC and inherit both ConfigMap and Secret as environment variables. Adapt the containers list to match the adapters you’ve enabled.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: arizuko
spec:
replicas: 1
selector:
matchLabels:
app: arizuko
template:
metadata:
labels:
app: arizuko
spec:
initContainers:
# seed the data dir on first run
- name: init-data
image: arizuko:latest
command: ["arizuko", "create", "$(INSTANCE_NAME)"]
envFrom:
- configMapRef:
name: arizuko-config
- secretRef:
name: arizuko-secrets
volumeMounts:
- name: data
mountPath: /srv/data
containers:
- name: gated
image: arizuko:latest
command: ["/usr/local/bin/gated"]
envFrom:
- configMapRef:
name: arizuko-config
- secretRef:
name: arizuko-secrets
volumeMounts:
- name: data
mountPath: /srv/data
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
- name: timed
image: arizuko:latest
command: ["/usr/local/bin/timed"]
envFrom:
- configMapRef:
name: arizuko-config
- secretRef:
name: arizuko-secrets
volumeMounts:
- name: data
mountPath: /srv/data
- name: onbod
image: arizuko:latest
command: ["/usr/local/bin/onbod"]
envFrom:
- configMapRef:
name: arizuko-config
- secretRef:
name: arizuko-secrets
volumeMounts:
- name: data
mountPath: /srv/data
- name: dashd
image: arizuko:latest
command: ["/usr/local/bin/dashd"]
envFrom:
- configMapRef:
name: arizuko-config
- secretRef:
name: arizuko-secrets
volumeMounts:
- name: data
mountPath: /srv/data
- name: proxyd
image: arizuko:latest
command: ["/usr/local/bin/proxyd"]
envFrom:
- configMapRef:
name: arizuko-config
- secretRef:
name: arizuko-secrets
volumeMounts:
- name: data
mountPath: /srv/data
ports:
- containerPort: 8080
name: http
# add adapter containers here, e.g.:
# - name: teled
# image: arizuko-teled:latest
# envFrom: [...]
volumes:
- name: data
persistentVolumeClaim:
claimName: arizuko-data
arizuko create. On subsequent
starts the data dir already exists and the command exits cleanly
(it skips re-seeding). You can also run create once
manually with kubectl run and omit the initContainer.
Expose proxyd’s port. Use a LoadBalancer for cloud
clusters; an Ingress for shared clusters where TLS termination is
already handled upstream.
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: arizuko
spec:
selector:
app: arizuko
ports:
- name: http
port: 80
targetPort: 8080
type: LoadBalancer
If your cluster has an Ingress controller (nginx, Traefik, etc.), use
type: ClusterIP here and add an Ingress resource with TLS.
proxyd can also handle TLS itself — set
PROXYD_TLS_CERT / PROXYD_TLS_KEY and expose
port 443 instead.
kubectl apply -f pvc.yaml
kubectl apply -f configmap.yaml
kubectl apply -f secret.yaml
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
# watch the pod come up
kubectl get pods -w
# check gated is ready
kubectl logs deploy/arizuko -c gated --tail=30
# hit the health endpoint
kubectl port-forward svc/arizuko 8080:80 &
curl -s http://localhost:8080/health
The External Secrets Operator syncs a K8s Secret from an external store on a schedule. When you rotate a secret in Vault or Secrets Manager, the operator updates the K8s Secret automatically, and the next pod restart picks it up.
Install the operator once per cluster:
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets -n external-secrets --create-namespace
Wire it to your store. This example uses AWS Secrets Manager with IRSA
(the pod’s ServiceAccount is annotated with an IAM role that has
secretsmanager:GetSecretValue):
# secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: aws-secrets
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: arizuko # annotated with IAM role ARN
# externalsecret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: arizuko-secrets
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets
kind: SecretStore
target:
name: arizuko-secrets # creates the K8s Secret
data:
- secretKey: AUTH_SECRET
remoteRef:
key: arizuko/demo
property: AUTH_SECRET
- secretKey: CHANNEL_SECRET
remoteRef:
key: arizuko/demo
property: CHANNEL_SECRET
- secretKey: TELEGRAM_BOT_TOKEN
remoteRef:
key: arizuko/demo
property: TELEGRAM_BOT_TOKEN
For HashiCorp Vault, swap the provider block for a Vault
provider and point at your KV path. For GCP Secret Manager, use the
GCP provider and Workload Identity instead of IRSA. The
ExternalSecret shape stays the same.
Once this is running, you never create secret.yaml
manually again. The operator manages the K8s Secret object; the
Deployment reads it; rotation is a push to Vault + a pod restart.
gated spawns a per-group arizuko-ant container
for each agent run. On bare Docker it calls
docker run via the Docker socket. In Kubernetes the socket
isn’t reliably available, and even if it is, containers spawned
that way land outside the pod’s namespace.
A CONTAINER_BACKEND=kubernetes mode is planned that will
create a Kubernetes Job per agent turn instead, inheriting the same
Secret and ConfigMap. It isn’t shipped yet. Until then:
dind
sidecar container (image: docker:dind) and mount its
socket into the gated container as
/var/run/docker.sock. Works; not recommended for
production because DinD requires privileged: true.
#observe route or
handle them via webhooks.
| variable | goes in | notes |
|---|---|---|
AUTH_SECRET |
Secret | 32-byte random; encrypts the secrets table in SQLite |
CHANNEL_SECRET |
Secret | bearer token adapters present to gated |
TELEGRAM_BOT_TOKEN |
Secret | teled adapter; omit if not using Telegram |
SLACK_BOT_TOKEN |
Secret | slakd adapter; omit if not using Slack |
SLACK_SIGNING_SECRET |
Secret | slakd adapter |
INSTANCE_NAME |
ConfigMap | short name; becomes data dir suffix |
ASSISTANT_NAME |
ConfigMap | shown in chat UIs and pane titles |
WEB_HOST |
ConfigMap | public URL including scheme; used in invite links |
CONTAINER_IMAGE |
ConfigMap | agent image name; gated pulls this per agent spawn |
HOST_DATA_DIR |
ConfigMap | host-side (or pod-side in K8s) path to data dir; passed to agent container as a bind-mount source |
HOST_APP_DIR |
ConfigMap |
in K8s: path inside the container image where skills live (e.g.
/workspace); no host bind-mount occurs
|
Pendingkubectl get pvc — if it’s
Pending, the StorageClass may not support RWO or no
provisioner is running. On a single-node cluster, use
local-path provisioner (Rancher) or set a
hostPath PV manually.
gated crashloops on startuparizuko create failed, it logs why
— missing env var, permission error, or existing corrupt DB.
Check kubectl logs deploy/arizuko -c init-data.
ROUTER_URL is set to
http://localhost:8080 (gated’s default
LISTEN_ADDR) in the adapter’s env.
refreshInterval. After sync,
the K8s Secret is updated but the running pod still has the old env.
Restart the pod: kubectl rollout restart deploy/arizuko.
To automate this, add the
Reloader
controller, which triggers a rollout when a watched Secret changes.
CONTAINER_BACKEND=kubernetes path isn’t shipped
yet; use DinD or skip agents until it is.