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
offwhile 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
- A network Zigbee coordinator, no USB.
- Mosquitto with per-client users and
allow_anonymous falseon day one. - Pinned image versions, upgraded on purpose. Home Assistant releases monthly and breaking changes happen.
- 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