Skip to content

Instantly share code, notes, and snippets.

@laanwj
Last active April 1, 2026 13:42
Show Gist options
  • Select an option

  • Save laanwj/927802ab565f2c8c3b81315a9c3632ed to your computer and use it in GitHub Desktop.

Select an option

Save laanwj/927802ab565f2c8c3b81315a9c3632ed to your computer and use it in GitHub Desktop.
Home Assistant on Raspberry Pi 5 (Debian 13) with Podman

Home Assistant on Raspberry Pi 5 (Debian 13) with Podman

Matter + Thread setup with ZBT-2 dongle, using Podman in rootful host-network mode.

Architecture

                     +-----------------+
                     | 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.


1. Prerequisites

1.1 Install packages

sudo apt update
sudo apt install -y podman avahi-daemon avahi-utils libnss-mdns python3-pip

Verify:

podman --version          # should be 5.4.x+
systemctl is-active avahi-daemon

IP 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.


2. Identify and flash the ZBT-2 dongle

2.1 Find the device path

ls -l /dev/serial/by-id/ | grep ZBT-2

You 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"

2.2 Flash Thread RCP firmware

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-flasher

Download 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.gbl

Wait for it to complete. The dongle will reconnect on the same path.


3. Container setup (Quadlet systemd units)

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.

3.1 Determine your network interface

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.

3.2 Volumes

for vol in homeassistant otbr matter-server; do
sudo tee /etc/containers/systemd/${vol}.volume > /dev/null << UNIT
[Volume]
VolumeName=${vol}
UNIT
done

This 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>.

3.3 OTBR

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
UNIT

3.4 Matter Server

sudo 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
UNIT

3.5 Home Assistant

sudo 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
UNIT

Change TZ="Europe/London" to your timezone (e.g. America/New_York, Europe/Berlin). Full list: timedatectl list-timezones.


4. Pull images and start

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.service

They will auto-start on boot (Quadlet's WantedBy=multi-user.target handles this).

4.1 Verify

sudo systemctl status otbr.service matter-server.service homeassistant.service
sudo podman ps

Check logs if something looks wrong:

journalctl -u otbr.service -f
journalctl -u matter-server.service -f
journalctl -u homeassistant.service -f

OTBR creates a wpan0 interface on the host:

ip addr show wpan0

Test the OTBR REST API:

curl http://localhost:8081/node/state
# Should return: "leader" or "router" or "child" (once the Thread network forms)

5. Home Assistant UI configuration

Open http://<pi-ip>:8123 in a browser. Complete the onboarding wizard first.

5.1 Add the OpenThread Border Router integration

  1. Settings > Devices & Services > + Add Integration
  2. Search for "OpenThread Border Router"
  3. Enter the URL: http://localhost:8081
  4. Confirm. Thread network info will appear under Settings > Devices & Services > Thread.

5.2 Add the Matter integration

  1. Settings > Devices & Services > + Add Integration
  2. Search for "Matter"
  3. Uncheck "Use the Matter Server add-on" (you're running your own)
  4. Enter the WebSocket URL: ws://localhost:5580/ws
  5. Confirm.

5.3 Commission a Matter device (via phone)

  1. In the Matter integration, click Add Matter Device
  2. Enter the device's setup code or scan its QR code (via the HA Companion app)
  3. 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).

5.4 IPv6 forwarding for commissioning

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 ACCEPT

Replace eth0 with your actual network interface. The accept_ra=2 setting ensures the Pi still accepts Router Advertisements despite forwarding being on.

5.5 Commission a Matter device (from Linux with Python CHIP controller)

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-commission

Inside 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-commission

6. Updating

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

7. Troubleshooting

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.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment