BETA — Open to testers. Tell us what to fix on @vomehome or via a tester code.

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.

Dongle 802.15.4 RCP Always-on board ours: Orange Pi Zero 3 Home LAN wired Ethernet USB Ethernet The mesh is on the left. Home Assistant is reached through the right. This box is both.
Do not put it on Wi-Fi. The border router forwards between the LAN and the mesh; a wireless uplink is a second radio problem you do not want.

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 pull that 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 clean first.
  • A dongle already flashed with OpenThread RCP firmware. Check with universal-silabs-flasher probe; ours reported SL-OPENTHREAD/2.4.4.0 at 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.

compose sysctls: looks right in the file ignored under host networking /etc/sysctl.d/ the kernel actually reads this forwarding and accept_ra = 2
A container that shares the host network cannot change the host's sysctls for you. Devices will join the mesh and then hang until this file exists.

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.

Next: adding a Matter-over-Thread device.

Rather not do this alone?
The Vome assistant will walk you through this guide a step at a time, and can do some of it for you.
Sign in for AI help

Something wrong or missing here? Tell us — these pages are written from real work, so corrections are welcome.