Life and Shell

Writing · homelab

Home Assistant, Zigbee2MQTT and Mosquitto on Kubernetes

By Mattias Hemmingsson · · 5 min read

My house is run by Home Assistant, and Home Assistant runs in my home Kubernetes cluster. So do Zigbee2MQTT, the Mosquitto MQTT broker, and the bits that talk to the solar inverter, the heat pump and the cameras.

This post shows how the pieces fit together and what I'd change if I started over.

The big picture

Zigbee devices ──radio──► Zigbee coordinator (on the LAN, TCP)
                                   │
                                   ▼
                           Zigbee2MQTT (pod)
                                   │ publish/subscribe
                                   ▼
Inverter, weather station, ──► Mosquitto (pod) ◄── Frigate (cameras)
heat pump, power meter             │
                                   ▼
                          Home Assistant (pod) ──► automations

MQTT is the bus. Every device or bridge publishes to Mosquitto, and Home Assistant picks things up through MQTT discovery. That keeps each piece small and replaceable: if Zigbee2MQTT is down, the rest of the house still works.

Home Assistant

Home Assistant is a plain Deployment with one replica and its /config on a Longhorn volume:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: homeassistant
  namespace: home
spec:
  replicas: 1
  strategy: { type: Recreate }   # never two pods writing the same /config
  selector:
    matchLabels: { app: homeassistant }
  template:
    metadata:
      labels: { app: homeassistant }
    spec:
      containers:
      - name: homeassistant
        image: ghcr.io/home-assistant/home-assistant:<version>
        env:
        - { name: TZ, value: Europe/Stockholm }
        ports:
        - { containerPort: 8123 }
        volumeMounts:
        - { name: config, mountPath: /config }
        resources:
          requests: { cpu: 100m, memory: 128Mi }
          limits:   { cpu: "1", memory: 2Gi }
      volumes:
      - name: config
        persistentVolumeClaim: { claimName: homeassistant-config }

It is exposed two ways:

  • An Ingress for the browser and the mobile app.
  • A LoadBalancer Service (MetalLB) with its own LAN IP. I run more than one Home Assistant, and they are linked with the Remote Home Assistant integration. That link is a long-lived WebSocket, and it is much more stable when it goes straight to the pod instead of through the ingress proxy.
apiVersion: v1
kind: Service
metadata:
  name: homeassistant-lan
  annotations:
    metallb.universe.tf/address-pool: lan
spec:
  type: LoadBalancer
  selector: { app: homeassistant }
  ports:
  - { name: ha, port: 8123, targetPort: 8123 }

Two instances: one that thinks, one that shows

The instance in the cluster runs automations only. It has no dashboards. A separate instance is the "display", the dashboards on the wall and the phones. Automations are kept as YAML files and pushed in through the Home Assistant API, so I can review them before they control real hardware.

You don't need this split to start. I added it when dashboard tinkering kept restarting the instance that runs the heating.

No host networking needed

Many guides say Home Assistant needs hostNetwork: true for discovery. Mine runs without it. Everything that matters reaches it over MQTT or a cloud/local API, so no mDNS magic is needed. That keeps the pod an ordinary pod: no port clashes, and it can move between nodes.

Zigbee2MQTT with a network coordinator

The usual Zigbee setup is a USB stick in the server. In Kubernetes that ties the pod to one node and needs a privileged container. I use a network Zigbee coordinator instead: a small box on the LAN with an Ember (EFR32) radio that Zigbee2MQTT reaches over TCP.

apiVersion: v1
kind: ConfigMap
metadata:
  name: zigbee2mqtt-config
data:
  configuration.yaml: |
    homeassistant:
      enabled: true
      discovery_topic: homeassistant
      status_topic: homeassistant/status
    mqtt:
      base_topic: zigbee2mqtt
      server: mqtt://mosquitto
      # user and password come from a Secret as env vars
    serial:
      port: tcp://<coordinator-ip>:6638
      adapter: ember
      baudrate: 115200
      rtscts: false
    frontend:
      enabled: true
      port: 8080

Zigbee2MQTT runs as a StatefulSet with one replica and a small volume for its device database. With a network coordinator:

  • the pod can run on any node, with no USB passthrough;
  • the coordinator can sit in the middle of the house, where the radio works best, instead of next to the server rack;
  • a node reboot is a pod restart, not a "which node had the stick" puzzle.

With homeassistant.enabled: true, every Zigbee device shows up in Home Assistant through MQTT discovery. No extra setup.

Mosquitto: one user per client

Mosquitto is a Deployment with its config in a ConfigMap and a password file in a Secret:

listener 1883
protocol mqtt

allow_anonymous false
password_file /mosquitto/config/passwordfile

Each client gets its own user: Home Assistant, Zigbee2MQTT, Frigate, the weather station bridge, the power meter. Create the file with mosquitto_passwd and store it as a Secret. If one device gets compromised, you change one password and can see in the logs who connected.

The broker has an in-cluster Service for pods and a LoadBalancer IP for devices on the LAN that publish directly, like the weather station.

Energy: solar, battery and the EV

This is where Home Assistant earns its keep. The house has solar panels, a home battery behind a Growatt inverter, and an EV charger. A few automations tie them together:

  • Night charging in the cheapest hours. At 22:00 an automation fetches tomorrow's Nord Pool prices (15-minute slots), finds the cheapest continuous block of N hours before 08:00, and saves the start and end time. A second automation charges the battery and the car in that window.
  • Solar surplus. When the panels produce more than the house uses, a flag is set. The car charges from the surplus, and the home battery is kept from discharging into the car.
  • Watchdogs. A guard automation runs every 10 minutes and puts the inverter back in the right mode if a setting was lost.

Things I learned the hard way:

  • Hardware lies a little. Setting the inverter's discharge limit to 0 did not reliably stop the battery from discharging; it still fed the car at over 6 kW. Test every "off" switch with a real load.
  • Readings go stale. The charger's switch entity can show off while the car is charging. Use the power reading, not the switch state, as the truth.
  • "Export power" clamps at 0. It shows surplus but never deficit, so compute the balance yourself.

Mistakes worth sharing

  • Copy-paste privilege. My Zigbee2MQTT manifest started as a copy of the Frigate one. It kept privileged: true, a USB mount and a node selector for the Coral accelerator, long after I moved to a network coordinator. Review what you copy.
  • "Temporary" anonymous MQTT. I turned on per-client users but left allow_anonymous true "until every client is moved". Set a date, or it stays forever.
  • The timezone. The upstream example had TZ=America/Los_Angeles, and so did my pod for a long time. Automations based on sunset were fine (they use your location), but logs were confusing.
  • Drift from git. Home Assistant's config lives on the volume and is edited in the UI. The volume was resized live, and the manifest still says the old size. For Home Assistant I treat the volume as the source of truth and back it up, and keep only automations in git.

What I'd do from the start

  1. A network Zigbee coordinator, no USB.
  2. Mosquitto with per-client users and allow_anonymous false on day one.
  3. Pinned image versions, upgraded on purpose. Home Assistant releases monthly and breaking changes happen.
  4. Automations as YAML in git, pushed through the API, with dashboards in a separate instance.

The best part: the house is now just another set of workloads in the cluster. It's backed up, monitored, and my AI agent can read the solar production when I ask it how much the panels made today.

Filed underhomeassistant · zigbee · mqtt · kubernetes · homelab