arizuko

arizukohowto › Kubernetes

deploy on Kubernetes

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. arizuko runs as three core daemons — authd (token authority), routd (conversation/router), runed (agent execution) — plus adapters and the web layer. They talk over HTTP (/v1/*); the per-turn agent MCP is a Unix socket routd hosts and runed mounts into the agent container. Each daemon owns its own SQLite DB on the shared PVC, and SQLite WAL has a single-writer constraint, so keep them in one pod (one replica). Split into separate Deployments only once you have a concrete reason.

Known gap — agent containers: on a bare Linux host, runed 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: SECRETS_KEY (the encryption key for the secrets table, read by routd), CHANNEL_SECRET, the per-daemon AUTHD_SERVICE_KEY each daemon uses to mint its service: token from authd, 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
authd container in Pod token authority; serves JWKS; must be first to start
routd container in Pod owns routd.db, hosts the per-turn agent MCP socket; dispatches turns to runed
runed container in Pod agent execution; the only daemon needing the Docker socket (see the agent-container gap)
timed, onbod, dashd containers in same Pod connect to routd/authd over loopback HTTP
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 SECRETS_KEY, CHANNEL_SECRET, AUTHD_SERVICE_KEY, 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"
  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:
  SECRETS_KEY: "change-me-32-char-random-string"
  CHANNEL_SECRET: "change-me-another-32-char-string"
  AUTHD_SERVICE_KEY: "change-me-per-daemon-service-key"
  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: authd
          image: arizuko:latest
          command: ["/usr/local/bin/authd"]
          env:
            - name: LISTEN_ADDR
              value: ":8081"
          envFrom:
            - configMapRef:
                name: arizuko-config
            - secretRef:
                name: arizuko-secrets
          volumeMounts:
            - name: data
              mountPath: /srv/data
          readinessProbe:
            httpGet:
              path: /health
              port: 8081
            initialDelaySeconds: 5
            periodSeconds: 10

        - name: routd
          image: arizuko:latest
          command: ["/usr/local/bin/routd"]
          env:
            # authd's JWKS + token mint, and the runed dispatch target.
            # Containers in one pod share localhost; give each daemon its
            # own port (LISTEN_ADDR) so they don't collide.
            - name: LISTEN_ADDR
              value: ":8080"
            - name: AUTHD_URL
              value: "http://localhost:8081"
            - name: RUNED_URL
              value: "http://localhost:8082"
          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: runed
          image: arizuko:latest
          command: ["/usr/local/bin/runed"]
          env:
            - name: LISTEN_ADDR
              value: ":8082"
            - name: AUTHD_URL
              value: "http://localhost:8081"
          envFrom:
            - configMapRef:
                name: arizuko-config
            - secretRef:
                name: arizuko-secrets
          volumeMounts:
            - name: data
              mountPath: /srv/data

        - 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 routd is ready
kubectl logs deploy/arizuko -c routd --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: SECRETS_KEY
      remoteRef:
        key: arizuko/demo
        property: SECRETS_KEY
    - 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)

runed 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
SECRETS_KEY Secret 32-byte random; routd uses it to encrypt the secrets table in SQLite
AUTHD_SERVICE_KEY Secret per-daemon key each daemon presents to authd to mint its service: token
CHANNEL_SECRET Secret bearer token adapters present to routd
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; runed 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.
routd 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 routd
All containers in the same Pod share localhost. Confirm ROUTER_URL is set to http://localhost:8080 (routd’s port in this manifest) in the adapter’s env, and that adapters wait for routd and authd to be ready.
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.