Build a Thread border router
An Orange Pi and a Sonoff MG24 dongle, running OpenThread for a hosted Home Assistant — including the five config traps that each cost an afternoon.
Updated 26 August 2026 matterthreadhardwarehomelink
A Thread border router is a radio plus a small always-on Linux machine on your LAN. A hosted Home Assistant cannot be one — the radio has to be where the devices are — so this is the one piece of the Matter path you build yourself.
How Thread and Matter actually work is the picture of why: Thread is an 802.15.4 mesh, not Wi-Fi, and this box is the only node that also speaks Ethernet.
Ours: an Orange Pi Zero 3 running Armbian, with a Sonoff Dongle Plus MG24 on USB. Any board and any supported 802.15.4 dongle will do. The config files that go with this article live in the OrangePiThreadBorderRouter folder — compose file, sysctls, kernel modules and the watchdog.
Before you start
- Wired Ethernet. The border router forwards between your LAN and the mesh; do not put it on Wi-Fi.
- Space on the disk. Ours is a 3.5 GB root partition, and a
docker pullthat runs out of disk mid-extraction corrupts containerd's own metadata — after which fresh pulls of the same image keep failing on stale snapshot records rather than on space.apt-get cleanfirst. - A dongle already flashed with OpenThread RCP firmware. Check with
universal-silabs-flasher probe; ours reportedSL-OPENTHREAD/2.4.4.0at 460800 baud and needed nothing.
Use the Home Assistant image
Run ghcr.io/ownbee/hass-otbr-docker, pinned to a tag, not
openthread/otbr:latest.
The upstream Docker Hub image ships two entrypoint flavours and wires the
project's internal test one as the container entrypoint. It is a CI build,
not what Home Assistant users run, and it produces exactly the
"no border routers found" symptom that will then send you looking at your
network. The ownbee image is built from Home Assistant's own add-on source
and carries its patches — it even announces itself over mDNS as
Home Assistant OpenThread Border Router.
Pin the tag (arm64-v0.3.0 for us). Part of the trouble above was tracking a
rolling build.
The five traps
Each of these presents as something else entirely.
| Setting | Trap |
|---|---|
| Backbone interface | The upstream image reads BACKBONE_INTERFACE; INFRA_IF_NAME is a build argument and does nothing at runtime. It silently defaults to eth0, which does not exist on this board (end0 does). |
| Serial device | Do not map /dev/serial/by-id/.... Docker's device mapping does not resolve the symlink, the path is empty inside the container, and otbr-agent fails with No such file or directory. Map the real node, /dev/ttyUSB0. |
| Kernel modules | The firewall setup runs ip6tables -N OTBR_FORWARD_INGRESS and fails with Table does not exist. Load ip6table_filter and friends on the host — the container's own modprobe fails silently, there is no sudo in the image. Persist it in /etc/modules-load.d/. |
| Listen address | The REST API and web UI bind 127.0.0.1 by default, so Home Assistant is refused while curl localhost works. Set HTTP_HOST=0.0.0.0. |
| Flow control | The reference compose sets FLOW_CONTROL: 1. This dongle does not need UART hardware flow control and the radio fails to init (Wait for response timeout). Set it to 0. |
If you migrate between images, note the persistent-data path changes
(/data/thread rather than /var/lib/thread). Copy the dataset across first
or you will lose the network.
Host settings Docker cannot make
With network_mode: host, compose sysctls: are ignored. These have to be
a host file — ours is /etc/sysctl.d/99-openthread-border-router.conf.
Put these in that file:
net.ipv6.conf.all.forwarding = 1
net.ipv6.conf.default.forwarding = 1
net.ipv6.conf.end0.accept_ra = 2
net.ipv6.conf.end0.accept_ra_rt_info_max_plen = 64
Without forwarding, devices join the mesh and then commissioning hangs on checking connectivity to Thread network — the phone tries to reach the new device across the mesh prefix and the kernel refuses to pass it.
accept_ra = 2 is not optional. The kernel ignores router advertisements once
an interface is forwarding unless it is explicitly 2, and the Pi would lose
its own IPv6 default route.
Do not press the web UI's Form Network button
The button arrives with the OpenThread project's tutorial dataset already
filled in — network key 00112233445566778899aabbccddeeff, name
OpenThreadDemo, PAN ID 0x1234. Those values are published in every
getting-started guide. We clicked it and replaced a properly random
network with a public one. Form the network with ot-ctl instead:
ot-ctl dataset init new
ot-ctl dataset commit active
ot-ctl ifconfig up
ot-ctl thread start
Checks that mean something
docker exec otbr ot-ctl state
docker exec otbr ot-ctl br counters
sysctl net.ipv6.conf.all.forwarding
leader
br counters is the useful one: after a device is commissioned, inbound and
outbound unicast must climb. Zero counters with a device that has joined the
mesh means forwarding, not radio.
The restart nobody warns you about
After any Home Assistant restart or update, the Thread panel may go back to "no border routers found" while the border router is untouched and healthy. It has happened to us twice. Restart the border-router container to force a fresh mDNS announcement, timed after Home Assistant has finished coming up.
There is no lighter fix. Bouncing the bundled mdnsd alone leaves otbr-agent
unpublished — it races the not-yet-ready replacement, fails once and does not
retry. The full restart costs a 15–20 second reattach, which does not affect
devices already commissioned; they talk to the mesh, not to the panel.
Because that is an upstream bug and not something to fix by hand at midnight,
we run a watchdog that asks Home Assistant whether it can still see the router
(thread/discover_routers over the websocket API) and restarts the container
only when the answer is no, with a cooldown so it cannot flap. Any error —
Home Assistant unreachable, token rejected — counts as "do not restart". The
script is in the repo linked above.
Two things not to start
The leftover official OTBR add-on on the hosted VM. Supervisor may still
have it installed from an earlier attempt. It is stopped, with device: null,
because there is no radio on the VM. Starting it does nothing useful and
competes with the Pi for Home Assistant's attention. The Thread panel is
using the Pi.
A Matter Server on the same box as the border router. We stood one up
during the Bluetooth detour, then parked it. Left running, it shares UDP 5353
with OTBR's mdnsd and advertises a second, unused Matter fabric
(_matter._tcp) on the same host. In the compose file it sits behind
profiles: ["unused"] so docker compose up -d cannot bring it back. Matter
Server belongs on Home Assistant, with ble_proxy on.