Life and Shell

Writing · kubernetes

Run your own AI home agents on Kubernetes: OpenClaw, Zoe and Dobby

By Mattias Hemmingsson · · 6 min read

I have two AI agents that never sleep. Dobby looks after the house: solar and battery, the EV charger, the network, Pi-hole. Zoe helps me with work: GitHub, writing, Ollebo. I talk to both over Telegram and IRC, and both run on OpenClaw in my home Kubernetes cluster.

This post shows how they are deployed, so you can run your own.

Why run agents in the cluster?

OpenClaw started on my laptop. That works until the laptop is closed, and an agent that runs the house has to be up when I am not. Moving it into the cluster gave me:

  • Always on. The pod restarts by itself, and the data lives on replicated storage (Longhorn).
  • Close to what it controls. Home Assistant, Pi-hole and the inverter API are all in the same cluster or on the same LAN.
  • One pattern, many agents. A second agent is just a copy of the manifest with a new name.

The shape of one agent

Each agent is one namespace with:

Object What it does
StatefulSet (1 replica) Runs the OpenClaw gateway
3 × PersistentVolumeClaim config (~/.openclaw), workspace (memory, skills, persona files) and home
Secret Every API key and token, loaded as environment variables
Service + Ingress The Control UI, only reachable from my LAN

Dobby and Zoe are the same image with different config. Running another persona means another namespace, not another product.

The StatefulSet

This is the core of it, cleaned up:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: dobby
  namespace: dobby
spec:
  serviceName: dobby
  replicas: 1
  selector:
    matchLabels: { app: dobby }
  template:
    metadata:
      labels: { app: dobby }
    spec:
      securityContext:
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
      containers:
      - name: openclaw
        # Pin by digest. :latest broke boot for me once.
        image: ghcr.io/openclaw/openclaw@sha256:<digest>
        command: ["node", "dist/index.js", "gateway",
                  "--bind", "lan", "--port", "18789", "--allow-unconfigured"]
        env:
        - { name: HOME, value: /home/node }
        - { name: NODE_ENV, value: production }
        # Trust X-Forwarded-* from the ingress controller (your Service CIDR)
        - { name: OPENCLAW_TRUSTED_PROXY_NETWORK, value: 10.96.0.0/12 }
        envFrom:
        - secretRef: { name: dobby-secrets }
        resources:
          requests: { cpu: "2", memory: 4Gi }
          limits:   { cpu: "2", memory: 4Gi }
        ports:
        - { containerPort: 18789, name: http }
        - { containerPort: 18790, name: bridge }
        volumeMounts:
        - { name: config,    mountPath: /home/node/.openclaw }
        - { name: workspace, mountPath: /home/node/.openclaw/workspace }
        - { name: home,      mountPath: /home/ }
      volumes:
      - name: config
        persistentVolumeClaim: { claimName: dobby-config }
      - name: workspace
        persistentVolumeClaim: { claimName: dobby-workspace }
      - name: home
        persistentVolumeClaim: { claimName: dobby-home }

A few choices are worth explaining:

  • --bind lan makes the gateway listen on the pod IP instead of localhost, so the Service can reach it.
  • OPENCLAW_TRUSTED_PROXY_NETWORK tells OpenClaw to trust the forwarded headers from the ingress controller. Without it, requests through the Ingress look like they come from the wrong place.
  • Requests equal limits. That gives the pod Guaranteed QoS, so it is the last thing evicted. I started at 2 GiB and had to raise it: with many chat sessions and the memory index, the gateway sits around 2 GiB RSS.
  • Three volumes. The workspace (memory files, SOUL.md, skills) is what I care about most, so it gets its own volume. That makes it easy to back up or move on its own.

Secrets: referenced, never written into the config

OpenClaw's config file, openclaw.json, lives on the config volume and is edited by the agent and the Control UI. I never want an API key in that file. Instead:

  1. Every credential is in a Kubernetes Secret, loaded with envFrom.
  2. openclaw.json only references them: {"source": "env", "id": "OC_TELEGRAM_BOT_TOKEN"}.
  3. A small script merges a local env file (outside git, mode 600) into the Secret and restarts the pod.
kubectl create secret generic dobby-secrets -n dobby \
  --from-literal=ANTHROPIC_API_KEY='<key>' \
  --from-literal=OC_GOOGLE_API_KEY='<key>' \
  --from-literal=OC_TELEGRAM_BOT_TOKEN='<token>' \
  --from-literal=OPENCLAW_GATEWAY_TOKEN='<random>'

This way the config can be copied, diffed and even shown to the agent without leaking anything.

Memory with local embeddings

OpenClaw's memory plugin builds a vector index over the agent's memory files. My agents used a hosted embedding API for that, and it shared a spend cap with the chat model. When the cap was hit, chat fell back to another model fine, but memory indexing silently stopped.

So the embeddings now run in the cluster with Ollama and embeddinggemma:300m, a small multilingual model:

initContainers:
- name: pull-model
  image: ollama/ollama:<version>
  command: ["/bin/sh", "-c"]
  args:
  - |
    ollama serve & pid=$!
    until ollama list >/dev/null 2>&1; do sleep 1; done
    ollama pull embeddinggemma:300m
    kill $pid
  env:
  - { name: OLLAMA_MODELS, value: /models }
  - { name: HOME, value: /tmp }
  volumeMounts:
  - { name: models, mountPath: /models }
  - { name: tmp, mountPath: /tmp }
containers:
- name: ollama
  image: ollama/ollama:<version>
  args: ["serve"]
  env:
  - { name: OLLAMA_HOST, value: 0.0.0.0:11434 }
  - { name: OLLAMA_MODELS, value: /models }
  - { name: HOME, value: /tmp }
  - { name: OLLAMA_KEEP_ALIVE, value: "-1" }   # keep the model loaded
volumes:
- { name: models, emptyDir: {} }
- { name: tmp, emptyDir: {} }

The init container starts a temporary Ollama server, waits until it answers, pulls the model and stops it again.

The model is about 600 MB, so it is pulled into an emptyDir instead of a volume. The pod can start on any node and just pulls again. OLLAMA_KEEP_ALIVE=-1 keeps the model loaded, so a memory lookup doesn't wait for a cold load.

In openclaw.json:

"memory": { "search": {
  "provider": "ollama",
  "model": "embeddinggemma:300m",
  "remote": { "baseUrl": "http://ollama-embed.embeddings.svc.cluster.local:11434" },
  "fallback": "none"
} }

It costs nothing, and memory content never leaves the house. If you change the embedding model, the vector size changes, so rebuild the index: openclaw memory status --index --agent main.

Channels: Telegram and IRC

Both agents have their own Telegram bot and their own IRC account. A few things I learned:

  • Use allowlists. Zoe only answers people on her allowlist; Dobby only takes commands from me on IRC. An agent that can open the garage door should not answer strangers.
  • Telegram groups need groupAllowFrom. With groupPolicy: allowlist and an empty list, every group message is dropped without a clear error.
  • IRC is a great agent bus. My CI runners, scripts and other agents post status in IRC channels too, so one client shows what everything is doing.

Cloning an agent: how Zoe was born

Zoe started as a copy of Dobby. The trick is that the gateway crashes on first boot without a valid config, so you boot the pod idle first:

  1. Copy the manifest and rename everything (namespace, names, Secret, PVCs, host).
  2. Swap the command for ["tail", "-f", "/dev/null"] and apply. The pod starts and does nothing.
  3. Copy the data in with tar:

bash tar -C ~/.openclaw -cf - --exclude=./workspace . | \ kubectl exec -i -n zoe zoe-0 -- \ tar -xf - -C /home/node/.openclaw/ --no-same-owner --no-same-permissions

  1. Fix the paths in openclaw.json that came from the old machine (for example agents.defaults.workspace).
  2. Put the real command back and apply again.

tar complains about Cannot utime on the root folder. That's harmless.

Gotchas

  • An invalid openclaw.json crash-loops the gateway with only "The CLI command failed". Validate a copy before you restart:

bash cp openclaw.json /tmp/t.json # edit /tmp/t.json OPENCLAW_CONFIG_PATH=/tmp/t.json openclaw config validate

  • Pin the image by digest and upgrade on purpose. A :latest pull once gave me an agent that would not boot.
  • The config is not in git. It lives on the volume because the agent edits it. Back up the volumes (Longhorn snapshots) instead of pretending it's GitOps.
  • Keep the Control UI internal. It's an admin panel for something holding your API keys. Mine is only reachable on the LAN.

What I'd do differently

  • Start with the Secret-plus-reference pattern from day one. Moving keys out of the config later was the most tedious part.
  • Give each agent a NetworkPolicy so Dobby can reach Home Assistant and Zoe can't.
  • Run the embeddings service from the start. Memory quietly stopping is worse than an error.

Two agents, about 8 GiB of RAM, and they're up whether my laptop is or not. If you already run a home cluster, this is a fun weekend project.

Filed underkubernetes · ai · openclaw · homelab