Matter + Thread setup with ZBT-2 dongle, using Podman in rootful host-network mode.
+-----------------+
| Home Assistant | :8123 (web UI)
| (container) |
+----+-------+----+
| |
ws://:5580 | | http://:8081
v v
+-----------+--+ +--+-----------+
| matter-server| | OTBR | :8080 (OTBR web UI)
| (container) | | (container) |
+--------------+ +------+-------+
|
/dev/serial/by-id/...
|
[ ZBT-2 ]
(Thread RCP radio)
All containers run with --network=host. Startup order: OTBR -> matter-server -> Home Assistant.
sudo apt update
sudo apt install -y podman avahi-daemon avahi-utils libnss-mdns python3-pipVerify:
podman --version # should be 5.4.x+
systemctl is-active avahi-daemonIP forwarding is intentionally not enabled. The Thread mesh is kept isolated
from the LAN -- devices can only communicate with the matter-server on the host,
not with the internet or other LAN hosts. If you later need to debug OTBR
routing issues, you can capture traffic on wpan0 with Wireshark/tcpdump.
ls -l /dev/serial/by-id/ | grep ZBT-2You should see something like:
usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00 -> ../../ttyACM0
Note the full path -- you'll use it everywhere below. We'll call it $ZBT2_DEV:
# Set this to YOUR actual device path:
export ZBT2_DEV="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00"The ZBT-2 ships with Zigbee firmware. It must be flashed to Thread RCP mode. It cannot run both protocols simultaneously.
pip install --break-system-packages universal-silabs-flasherDownload the latest RCP firmware from
https://github.com/NabuCasa/silabs-firmware-builder/releases --
look for a file named like zbt2_openthread_rcp_*.gbl.
# Example (check the releases page for the latest version):
wget https://github.com/NabuCasa/silabs-firmware-builder/releases/download/v2026.02.23/zbt2_openthread_rcp_2.7.2.0_GitHub-fb0446f53_gsdk_2025.6.2.gbl
universal-silabs-flasher \
--device "$ZBT2_DEV" \
flash \
--profile zbt2 \
--firmware zbt2_openthread_rcp_2.7.2.0_GitHub-fb0446f53_gsdk_2025.6.2.gblWait for it to complete. The dongle will reconnect on the same path.
Quadlet files go in /etc/containers/systemd/ (rootful). Podman's systemd
generator automatically converts them to services. After creating or editing
any of these files, run sudo systemctl daemon-reload.
ip route | grep default
# e.g. "default via 192.168.1.1 dev eth0" or "... dev end0"Use whatever interface name appears (commonly eth0, end0, or wlan0).
Replace eth0 in the files below if yours differs.
for vol in homeassistant otbr matter-server; do
sudo tee /etc/containers/systemd/${vol}.volume > /dev/null << UNIT
[Volume]
VolumeName=${vol}
UNIT
doneThis creates three named Podman volumes. Data is stored under
/var/lib/containers/storage/volumes/. You can inspect or back them up with
podman volume inspect <name> and podman volume export <name>.
Replace XXXXXXXXXX with your actual ZBT-2 serial in both AddDevice and
the DEVICE environment variable.
sudo tee /etc/containers/systemd/otbr.container > /dev/null << 'UNIT'
[Unit]
Description=OpenThread Border Router
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=otbr
Image=ghcr.io/ownbee/hass-otbr-docker
Network=host
AddCapability=SYS_ADMIN NET_ADMIN
AddDevice=/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00
AddDevice=/dev/net/tun
Volume=otbr.volume:/var/lib/thread
Environment=DEVICE="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_XXXXXXXXXX-if00"
# BACKBONE_IF is not used (no forwarding/NAT64), but the container's startup
# script crashes if it's unset and eth0 doesn't exist. Set to your actual
# interface name to avoid this.
Environment=BACKBONE_IF="eth0"
Environment=FLOW_CONTROL=1
Environment=FIREWALL=0
Environment=NAT64=0
Environment=BAUDRATE=460800
Environment=OTBR_REST_PORT=8081
Environment=OTBR_WEB_PORT=8080
Environment=AUTOFLASH_FIRMWARE=0
PodmanArgs=--privileged
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target
UNITsudo tee /etc/containers/systemd/matter-server.container > /dev/null << 'UNIT'
[Unit]
Description=Python Matter Server
After=network-online.target otbr.service
Wants=network-online.target
[Container]
ContainerName=matter-server
Image=ghcr.io/matter-js/python-matter-server:stable
Network=host
SecurityLabelDisable=true
Volume=matter-server.volume:/data
Volume=/run/dbus:/run/dbus:ro
Exec=--storage-path /data --paa-root-cert-dir /data/credentials --bluetooth-adapter 0
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target
UNITsudo tee /etc/containers/systemd/homeassistant.container > /dev/null << 'UNIT'
[Unit]
Description=Home Assistant
After=network-online.target matter-server.service otbr.service
Wants=network-online.target
[Container]
ContainerName=homeassistant
Image=ghcr.io/home-assistant/home-assistant:stable
Network=host
Volume=homeassistant.volume:/config
Volume=/run/dbus:/run/dbus:ro
Environment=TZ="Europe/London"
Environment=DISABLE_JEMALLOC=true
PodmanArgs=--privileged
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target
UNITChange TZ="Europe/London" to your timezone (e.g. America/New_York,
Europe/Berlin). Full list: timedatectl list-timezones.
sudo systemctl daemon-reload
# Pull all images first (can take a while on Pi):
sudo podman pull ghcr.io/ownbee/hass-otbr-docker
sudo podman pull ghcr.io/matter-js/python-matter-server:stable
sudo podman pull ghcr.io/home-assistant/home-assistant:stable
# Start in order:
sudo systemctl start otbr.service
sudo systemctl start matter-server.service
sudo systemctl start homeassistant.serviceThey will auto-start on boot (Quadlet's WantedBy=multi-user.target handles this).
sudo systemctl status otbr.service matter-server.service homeassistant.service
sudo podman psCheck logs if something looks wrong:
journalctl -u otbr.service -f
journalctl -u matter-server.service -f
journalctl -u homeassistant.service -fOTBR creates a wpan0 interface on the host:
ip addr show wpan0Test the OTBR REST API:
curl http://localhost:8081/node/state
# Should return: "leader" or "router" or "child" (once the Thread network forms)Open http://<pi-ip>:8123 in a browser. Complete the onboarding wizard first.
- Settings > Devices & Services > + Add Integration
- Search for "OpenThread Border Router"
- Enter the URL:
http://localhost:8081 - Confirm. Thread network info will appear under Settings > Devices & Services > Thread.
- Settings > Devices & Services > + Add Integration
- Search for "Matter"
- Uncheck "Use the Matter Server add-on" (you're running your own)
- Enter the WebSocket URL:
ws://localhost:5580/ws - Confirm.
- In the Matter integration, click Add Matter Device
- Enter the device's setup code or scan its QR code (via the HA Companion app)
- Thread devices are automatically commissioned via OTBR
There's no auto-update with Container installs. To update:
sudo podman pull ghcr.io/home-assistant/home-assistant:stable
sudo systemctl restart homeassistant.service
sudo podman pull ghcr.io/matter-js/python-matter-server:stable
sudo systemctl restart matter-server.service
sudo podman pull ghcr.io/ownbee/hass-otbr-docker
sudo systemctl restart otbr.service- Power supply: Use the official 27W (5V/5A) USB-C PSU. Underpowered supplies cause instability under load.
- Cooling: Active cooling (fan case) is required. The Pi 5 throttles aggressively without it.
- USB 3.0 RF interference: USB 3.0 ports emit noise in the 2.4 GHz band, which degrades Zigbee/Thread/BLE radio performance. Use a short USB extension cable between the ZBT-2 and the Pi to add physical distance.
| Symptom | Check |
|---|---|
| OTBR won't start | journalctl -u otbr.service -- look for device permission errors. Verify $ZBT2_DEV exists and has Thread RCP firmware. |
No wpan0 interface |
OTBR needs --privileged and /dev/net/tun. Check ip link show wpan0. |
| Matter server won't connect | Verify port 5580 is listening: ss -tlnp | grep 5580 |
| HA can't find OTBR | Verify curl http://localhost:8081/node/state returns a response. |
| mDNS discovery not working | systemctl is-active avahi-daemon -- must be running. All containers must use host networking. |
| Thread devices unreachable | Check IPv6 forwarding: sysctl net.ipv6.conf.all.forwarding (must be 1). Check ip -6 route for Thread prefixes. |
SQUASHFS or boot errors from USB SSD |
Use USB 2.0 port for boot media (Pi 5 known issue). Not relevant if booting from SD/NVMe. |