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 AddDevice.
The host's stable by-id path is mapped to /dev/zbt2 inside the container.
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:/dev/zbt2
AddDevice=/dev/net/tun
Volume=otbr.volume:/var/lib/thread
Environment=DEVICE="/dev/zbt2"
# 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 --listen-address 127.0.0.1
[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
Note: The phone must be on the same subnet as the Pi, and must be able to discover the OTBR via mDNS. On Android, Google Play Services handles the BLE commissioning -- if the phone is on a different subnet, it will fail with "Your device needs a Thread border router." IPv6 forwarding must also be enabled on the Pi for the phone to reach the Thread mesh (see section 5.4).
Commissioning requires the commissioning device (phone or laptop) to reach the Thread mesh via IPv6. Enable forwarding, but restrict it to traffic between your LAN and the Thread mesh only (blocking Thread devices from reaching the internet):
sudo sysctl net.ipv6.conf.all.forwarding=1
sudo sysctl net.ipv6.conf.all.accept_ra=2
sudo ip6tables -P FORWARD DROP
sudo ip6tables -A FORWARD -i eth0 -o wpan0 -j ACCEPT
sudo ip6tables -A FORWARD -i wpan0 -o eth0 -j ACCEPTReplace eth0 with your actual network interface. The accept_ra=2 setting
ensures the Pi still accepts Router Advertisements despite forwarding being on.
This runs in a purpose-built container that includes all dependencies, BLE support, and PAA root certificates.
Build the commissioning container (one-time, from a machine with Podman):
Create a file called Containerfile:
FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2-dev \
libgirepository1.0-dev \
libdbus-1-dev \
libnl-route-3-dev \
pkg-config \
gcc \
git \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir \
home-assistant-chip-core \
home-assistant-chip-repl \
home-assistant-chip-clusters \
aiohttp
# Fetch PAA root certificates
RUN git clone --depth 1 --filter=blob:none --sparse \
https://github.com/project-chip/connectedhomeip.git /opt/paa-certs \
&& cd /opt/paa-certs \
&& git sparse-checkout set credentials/development/paa-root-certs \
&& rm -rf .git
# Create the /data directory that chip-core hardcodes
RUN mkdir -p /data
ENTRYPOINT ["chip-repl", "-t", "/opt/paa-certs/credentials/development/paa-root-certs"]Build it (with sudo, so root's Podman store has it for the run step):
sudo podman build -t matter-commission .Run the commissioning REPL:
Must run as root for D-Bus/BlueZ access.
sudo podman run --rm -it --network=host --privileged \
-v /run/dbus:/run/dbus \
-v /run/avahi-daemon/socket:/var/run/avahi-daemon/socket \
localhost/matter-commissionInside the REPL, commission the device:
Make sure that mDNS on the OTBR_HOST is reachable from the commissioning host and not blocked by a firewall, or a different subnet.
import aiohttp, json
from chip.setup_payload import SetupPayload
# --- Configuration ---
OTBR_HOST = "192.168.x.x" # IP of the Pi running OTBR
MATTER_SERVER = "192.168.x.x" # IP of the Pi running matter-server
QR_CODE = "MT:..." # MT: string from device packaging
# --- Parse QR code ---
payload = SetupPayload().ParseQrCode(QR_CODE)
pin = payload.setup_passcode
disc = payload.long_discriminator
# --- Fetch Thread dataset from OTBR ---
async with aiohttp.ClientSession() as session:
resp = await session.get(
f"http://{OTBR_HOST}:8081/node/dataset/active",
headers={"Accept": "text/plain"})
dataset = bytes.fromhex(await resp.text())
# --- Commission the device over BLE ---
# (make sure the device is in pairing mode first)
await devCtrl.CommissionThread(
discriminator=disc, setupPinCode=pin, nodeId=1,
threadOperationalDataset=dataset)
# --- Open commissioning window for HA's matter-server ---
params = await devCtrl.OpenCommissioningWindow(
nodeid=1, timeout=300, iteration=1000, discriminator=2365, option=1)
manual_code = params.setupManualCode
# --- Hand off to HA's matter-server ---
async with aiohttp.ClientSession() as session:
ws = await session.ws_connect(f"ws://{MATTER_SERVER}:5580/ws")
print(await ws.receive_json()) # server info
await ws.send_json({"message_id": "1", "command": "start_listening"})
await ws.send_json({
"message_id": "2",
"command": "set_thread_dataset",
"args": {"dataset": dataset.hex()}
})
await ws.send_json({
"message_id": "3",
"command": "commission_with_code",
"args": {"code": manual_code, "network_only": True}
})
# Wait for all responses (commissioning may take a while)
while True:
msg = await ws.receive_json()
print(msg)
if msg.get('message_id') == '3':
break
await ws.close()
# --- Remove chip-repl's fabric from the device (HA's fabric remains) ---
await devCtrl.UnpairDevice(1)Exit the REPL (exit()), then clean up the container:
sudo podman rmi localhost/matter-commissionThere'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| 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. |