deploy on Kubernetes

← arizuko · ← how-to

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.

Known gap — agent containers: on a bare Linux host, 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.

why Kubernetes for secret management

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.

architecture mapping

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
replicas: 1 only. SQLite WAL allows multiple readers but only one writer at a time. Running two pods against the same PVC will corrupt the database. If you need high availability, put SQLite on a writable-once volume and front the pod with a readiness probe + PodDisruptionBudget, or migrate to an external database (not currently supported).

HOST_APP_DIR in Kubernetes

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.

step 1 — PersistentVolumeClaim

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

step 2 — ConfigMap

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"

step 3 — Secret

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
Don’t commit secret.yaml to git. Use kubectl apply -f secret.yaml once, then delete the local file. Better: use External Secrets or Sealed Secrets so the repo never holds plaintext.

step 4 — Deployment

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
The init container runs 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.

step 5 — Service

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.

step 6 — apply and verify

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

secret management with External Secrets

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.

agent containers (known gap)

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:

env vars reference

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

troubleshooting

Pod stuck in Pending
Usually the PVC isn’t bound. Check kubectl 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 startup
Usually the data dir isn’t seeded. Check that the initContainer ran and exited 0. If arizuko create failed, it logs why — missing env var, permission error, or existing corrupt DB. Check kubectl logs deploy/arizuko -c init-data.
Adapter can’t reach gated
All containers in the same Pod share localhost and the IPC socket path. Confirm ROUTER_URL is set to http://localhost:8080 (gated’s default LISTEN_ADDR) in the adapter’s env.
Secrets not updating after Vault rotation
External Secrets syncs on 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.
Agent containers don’t spawn
See the agent containers section above. The CONTAINER_BACKEND=kubernetes path isn’t shipped yet; use DinD or skip agents until it is.