From Containerd to Kubernetes: Migrating Home Assistant, Mosquitto, and Zigbee2MQTT #
Introduction #
In this comprehensive guide, I’ll walk you through the process of migrating standalone containers running in containerd (managed via nerdctl) to Kubernetes. This session covers discovering non-Kubernetesized containers, creating Kubernetes manifests that preserve volumes and configurations, troubleshooting deployment issues, and successfully running IoT services in a Kubernetes cluster.
Whether you’re running Home Assistant, an MQTT broker, or Zigbee home automation services, this step-by-step tutorial will help you make the leap from standalone containers to container orchestration.
TLDR #
- Discovered 5 non-Kubernetes containers running via nerdctl: thincast, xrdp, homeassistant, mosquitto, and zigbee2mqtt
- Created Kubernetes manifests preserving all existing volumes and configurations using hostPath volumes
- Encountered and fixed a Kubernetes manifest error (invalid
devicesfield in Deployment) - Resolved port conflicts by stopping old containers before starting Kubernetes pods
- Successfully migrated all three services to Kubernetes with full functionality verified
Discovering Non-Kubernetesized Containers #
The first step was to identify what was running in containerd that wasn’t managed by Kubernetes. We used nerdctl to list all containers and then checked the Kubernetes namespace to identify which containers were NOT part of the k8s.io namespace.
Checking Containerd Containers #
# First attempt (without sudo) - failed due to rootless containerd not running
nerdctl ps -aOutput:
time="2026-02-21T00:53:23+02:00" level=fatal msg="rootless containerd not running? (hint: use `containerd-rootless-setuptool.sh install` to start rootless containerd): stat /run/user/1000/containerd-rootless: no such file or directory"Running with Sudo #
sudo nerdctl ps -aOutput:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8ef5a4c9deda registry.amzk8s.io/thincast-ltf:1.0.0 "/confine.sh /usr/sb…" 4 weeks ago Up 10.222.1.22:443->443/tcp, 10.222.1.22:80->80/tcp thincast
201ef8466c93 locals.tf/xrdp:latest "/home/user/start-kd…" 7 weeks ago Created 0.0.0.0:3389->3389/tcp xrdp
a0b74a61e429 ghcr.io/home-assistant/home-assistant:stable "/init" 2 months ago Up homeassistant
12ed5c83f8cf docker.io/library/eclipse-mosquitto:latest "/docker-entrypoint.…" 2 months ago Up 10.222.1.22:1883->1833/tcp mosquitto
0a6a196d7bb1 ghcr.io/koenkk/zigbee2mqtt:latest "docker-entrypoint.s…" 2 months ago Up 10.222.1.22:8080->8080/tcp zigbee2mqttChecking Kubernetes Namespace #
sudo nerdctl ps -a --namespace k8s.ioThis showed many Kubernetes-managed containers in the k8s.io namespace. The non-Kubernetes containers were in the default namespace:
| Container | Image | Status | Ports |
|---|---|---|---|
| thincast | registry.amzk8s.io/thincast-ltf:1.0.0 | Up | 443, 80 |
| xrdp | locals.tf/xrdp:latest | Created | 3389 |
| homeassistant | ghcr.io/home-assistant/home-assistant:stable | Up | - |
| mosquitto | docker.io/library/eclipse-mosquitto:latest | Up | 1883 |
| zigbee2mqtt | ghcr.io/koenkk/zigbee2mqtt:latest | Up | 8080 |
Inspecting Volume Configurations #
Before creating Kubernetes manifests, we needed to understand the volume configuration of each container. We used nerdctl inspect to extract mount information.
Home Assistant Volumes #
sudo nerdctl inspect a0b74a61e429 --format '{{json .Mounts}}'Output:
[
{"Type":"bind","Source":"/data/hass","Destination":"/config","Mode":"","RW":true,"Propagation":""},
{"Type":"bind","Source":"/run/dbus","Destination":"/run/dbus","Mode":"ro","RW":false,"Propagation":""}
]Mosquitto Volumes #
sudo nerdctl inspect 12ed5c83f8cf --format '{{json .Mounts}}'Output:
[
{"Type":"bind","Source":"/data/mosquitto","Destination":"/mosquitto/config","Mode":"","RW":true,"Propagation":""},
{"Type":"bind","Source":"/data/mosquitto/data","Destination":"/mosquitto/data","Mode":"","RW":true,"Propagation":""},
{"Type":"bind","Source":"/data/mosquitto/logs","Destination":"/mosquitto/logs","Mode":"","RW":true,"Propagation":""},
{"Type":"volume","Name":"8ef721084e53f8798f71e33009cf26e024000bda98da2a6c92c1ff99d83fc742","Source":"/var/lib/nerdctl/1935db59/volumes/default/8ef721084e53f8798f71e33009cf26e024000bda98da2a6c92c1ff99d83fc742/_data","Destination":"/mosquitto/log","Driver":"local","Mode":"","RW":true,"Propagation":""}
]Zigbee2MQTT Volumes #
sudo nerdctl inspect 0a6a196d7bb1 --format '{{json .Mounts}}'Output:
[
{"Type":"bind","Source":"/data/zigbee2mqtt/data","Destination":"/app/data","Mode":"","RW":true,"Propagation":""},
{"Type":"bind","Source":"/data/zigbee2mqtt/logs","Destination":"/app/logs","Mode":"","RW":true,"Propagation":""},
{"Type":"bind","Source":"/dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_e2207336378aef11bd64bea3ef8776e9-if00-port0","Destination":"/dev/ttyUSB0","Mode":"","RW":true,"Propagation":""}
]The zigbee2mqtt container requires a USB Zigbee dongle (Sonoff Zigbee 3.0 USB Dongle Plus) to be passed through.
Creating Kubernetes Manifests #
We created a development directory and wrote Kubernetes manifests for each service. The key design decisions were:
- hostNetwork: true - Preserves the existing IP address (10.222.1.22) and behavior
- hostPath volumes - Preserves the existing
/data/*paths on the host - privileged: true - Required for homeassistant and zigbee2mqtt for hardware access
- New namespace: home-iot - Isolated namespace for IoT services
Directory Setup #
mkdir -p ~/dev/hassk8sHome Assistant Manifest #
apiVersion: v1
kind: Service
metadata:
name: homeassistant
namespace: home-iot
spec:
selector:
app: homeassistant
ports:
- port: 8123
targetPort: 8123
name: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: homeassistant
namespace: home-iot
spec:
replicas: 1
selector:
matchLabels:
app: homeassistant
template:
metadata:
labels:
app: homeassistant
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: homeassistant
image: ghcr.io/home-assistant/home-assistant:stable
volumeMounts:
- name: config
mountPath: /config
- name: dbus
mountPath: /run/dbus
readOnly: true
securityContext:
privileged: true
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
volumes:
- name: config
hostPath:
path: /data/hass
- name: dbus
hostPath:
path: /run/dbusMosquitto Manifest #
apiVersion: v1
kind: Service
metadata:
name: mosquitto
namespace: home-iot
spec:
selector:
app: mosquitto
ports:
- port: 1883
targetPort: 1883
name: mqtt
- port: 9001
targetPort: 9001
name: mqtt-websockets
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mosquitto
namespace: home-iot
spec:
replicas: 1
selector:
matchLabels:
app: mosquitto
template:
metadata:
labels:
app: mosquitto
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: mosquitto
image: docker.io/library/eclipse-mosquitto:latest
volumeMounts:
- name: config
mountPath: /mosquitto/config
- name: data
mountPath: /mosquitto/data
- name: logs
mountPath: /mosquitto/logs
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
volumes:
- name: config
hostPath:
path: /data/mosquitto
- name: data
hostPath:
path: /data/mosquitto/data
- name: logs
hostPath:
path: /data/mosquitto/logsZigbee2MQTT Manifest #
apiVersion: v1
kind: Service
metadata:
name: zigbee2mqtt
namespace: home-iot
spec:
selector:
app: zigbee2mqtt
ports:
- port: 8080
targetPort: 8080
name: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: zigbee2mqtt
namespace: home-iot
spec:
replicas: 1
selector:
matchLabels:
app: zigbee2mqtt
template:
metadata:
labels:
app: zigbee2mqtt
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: zigbee2mqtt
image: ghcr.io/koenkk/zigbee2mqtt:latest
volumeMounts:
- name: data
mountPath: /app/data
- name: logs
mountPath: /app/logs
- name: serial
mountPath: /dev/ttyUSB0
securityContext:
privileged: true
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
env:
- name: TZ
value: "Europe/Helsinki"
volumes:
- name: data
hostPath:
path: /data/zigbee2mqtt/data
- name: logs
hostPath:
path: /data/zigbee2mqtt/logs
- name: serial
hostPath:
path: /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_e2207336378aef11bd64bea3ef8776e9-if00-port0Namespace and Kustomization #
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: home-iot# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: home-iot
resources:
- namespace.yaml
- homeassistant.yaml
- mosquitto.yaml
- zigbee2mqtt.yamlInitial Deployment and Troubleshooting #
First Deployment Attempt #
We started with a limited test deployment (mosquitto and zigbee2mqtt only) to minimize risk:
kubectl apply -k ~/dev/hassk8s/Output:
namespace/home-iot created
service/homeassistant created
service/mosquitto created
service/zigbee2mqtt created
deployment.apps/homeassistant created
deployment.apps/mosquitto created
Error from server (BadRequest): error when creating "/dev/hassk8s/": Deployment in version "v1" cannot be handled as a Deployment: strict decoding error: unknown field "spec.template.spec.devices"Fixing the Zigbee2MQTT Manifest #
The devices field is not valid for a standard Kubernetes Deployment. Instead, we need to use a hostPath volume to expose the USB device. We corrected the manifest:
Before (invalid):
spec:
containers:
- name: zigbee2mqtt
# ... container config
devices:
- hostPath: /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_e2207336378aef11bd64bea3ef8776e9-if00-port0
containerPath: /dev/ttyUSB0After (corrected):
spec:
containers:
- name: zigbee2mqtt
volumeMounts:
- name: serial
mountPath: /dev/ttyUSB0
# ... container config
volumes:
- name: serial
hostPath:
path: /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_e2207336378aef11bd64bea3ef8776e9-if00-port0Port Conflict Issues #
After the manifests were applied, pods entered CrashLoopBackOff or Error states. Checking the logs revealed the root cause:
tail -30 /data/mosquitto/logs/mosquitto.logOutput:
1771801044: Opening ipv4 listen socket on port 1883.
1771801044: Error: Address in use
1771801044: mosquitto version 2.1.2 terminatingThe old containerd containers were still running and holding the ports! We needed to stop them before the Kubernetes pods could start.
Stopping Old Containers #
sudo nerdctl stop mosquitto
sudo nerdctl stop zigbee2mqtt
sudo nerdctl stop homeassistantAfter stopping the old containers, we had to delete and recreate the pods since they were in CrashLoopBackOff:
kubectl delete pod -n home-iot mosquitto-f94c5fb76-qxwp2 zigbee2mqtt-5c99bc6889-ms5q6Final Pod Status #
After resolving all issues:
kubectl get pods -n home-iotOutput:
NAME READY STATUS RESTARTS AGE
homeassistant-66c57fc64b-4gzlh 1/1 Running 5 (2m22s ago) 5m46s
mosquitto-f94c5fb76-4g2kw 1/1 Running 0 7s
zigbee2mqtt-5c99bc6889-m29h7 1/1 Running 0 7sVerifying Functionality #
Testing Home Assistant #
curl -s http://10.222.1.22:8123/ | head -5Home Assistant was responding with its web interface. The logs showed it was connecting to a Roborock device:
2026-02-22 23:04:01.852 INFO (MainThread) [roborock.devices.rpc.v1_channel] [******fRN5E] Connected to deviceTesting Zigbee2MQTT #
curl -s http://10.222.1.22:8080/ | head -3The Zigbee2MQTT frontend was accessible. The logs showed successful initialization:
[2026-02-23 01:02:03] info: z2m: Coordinator firmware version: '{"meta":{"maintrel":1,"majorrel":2,"minorrel":7,"product":1,"revision":20250321,"transportrev":2},"type":"ZStack3x0"}'
[2026-02-23 01:02:03] info: z2m: Connecting to MQTT server at mqtt://10.222.1.22:1883
[2026-02-23 01:02:04] info: z2m: Connected to MQTT server
[2026-02-23 01:02:04] info: z2m: Zigbee2MQTT started!Testing Mosquitto #
nc -zv 10.222.1.22 1883 2>&1Output:
Connection to 10.222.1.22 1883 port [tcp/*] succeeded!Verifying MQTT Message Flow #
We subscribed to the zigbee2mqtt topic to verify messages were flowing:
kubectl exec -n home-iot deploy/mosquitto -- mosquitto_sub -t 'zigbee2mqtt/#' -C 3 -i test-subThis confirmed MQTT messages were being published from Zigbee2MQTT to Mosquitto, including Home Assistant auto-discovery messages.
Cleaning Up Old Containers #
After verification, we removed the old containerd containers:
sudo nerdctl rm homeassistant mosquitto zigbee2mqttSummary #
Successfully migrated three critical IoT services from standalone containerd containers to Kubernetes:
| Service | Endpoint | Status |
|---|---|---|
| Home Assistant | http://10.222.1.22:8123 | ✓ Running |
| Mosquitto | 10.222.1.22:1883 | ✓ Running |
| Zigbee2MQTT | http://10.222.1.22:8080 | ✓ Running |
Key Takeaways #
-
Use hostPath volumes carefully: When migrating from standalone containers, hostPath volumes preserve your existing data paths but require the same paths to exist on Kubernetes nodes.
-
The
devicesfield doesn’t work in Deployments: To pass through hardware devices like USB dongles, use hostPath volumes with volumeMounts instead. -
Stop old containers before starting Kubernetes pods: Port conflicts will cause pods to crash if the old containers are still holding the ports.
-
hostNetwork mode has trade-offs: Using
hostNetwork: truepreserves the original IP address but bypasses some Kubernetes networking features. For production, consider using ClusterIP services with ingress. -
Verify before cleanup: Always verify that Kubernetes pods are working correctly before removing the original containers.
The manifests are now available in ~/dev/hassk8s/ and can be managed with kubectl apply -k ~/dev/hassk8s/. This provides the foundation for future GitOps deployments and easier management of your home IoT infrastructure.