Running Matter/Thread (with an external OTBR) alongside Home Assistant on Kubernetes

Running Matter/Thread (with an external OTBR) alongside Home Assistant on Kubernetes

This guide documents how I got a Thread Border Router (OTBR) that lives outside the
cluster
to work with Home Assistant and a Matter controller (matter.js server)
running as pods inside a Kubernetes cluster
. It assumes:

  • Home Assistant is already running as a container in your cluster (any distribution).
  • You are running Cilium as your CNI, with kube-proxy replacement
    and the Gateway API enabled.
  • Your Thread Border Router is a physically separate device on the same LAN (in my
    case a Zigbee/Thread combo dongle, an SLZB-MR4U, but any standalone OTBR - e.g. a
    Raspberry Pi running otbr-agent, a HomeAssistant Yellow, an Aeotec/SkyConnect stick
    plugged into another host, etc. - hits the same issues).

If your OTBR runs as a container in the same cluster, most of this still applies,
except the “getting mDNS to cross the cluster boundary” part (Challenge 1) becomes
“getting mDNS across the pod network” instead - the routing problem described in
Challenge 3 still applies either way.

None of this is Home-Assistant-specific config - it’s all Kubernetes/Cilium/Talos
networking. If you’re on a managed CNI (e.g. cloud LoadBalancer + Calico/Flannel) some of
the exact commands will differ, but the underlying problems (multicast doesn’t cross pod
networks, RFC 4191 routes are opt-in, admission policies block hostNetwork) are
universal to any CNI-based cluster.

Please note that parts of this guide have been written by AI, as well as the steps I did to overcome all the challenges to finally get everything working. Nonetheless I think, there is value in this guide as it still took me days to figure out all problems and as far, as I can tell this has never been attempted by anyone before.

Why this is hard: the short version

Matter-over-Thread relies on two things that Kubernetes’ pod network was never designed
to carry:

  1. mDNS multicast (UDP 5353) - both for discovering the Matter device’s operational
    address, and for discovering the Border Router itself (_meshcop._udp, _trel._udp).
  2. A specific IPv6 route into the Thread mesh - the OTBR advertises reachability to
    its Thread “OMR prefix” (the mesh’s off-mesh-routable IPv6 range) via Router
    Advertisement Route Information Options (RFC 4191)
    . Regular hosts (and container
    network namespaces) don’t pick this up by default.

Container network overlays (Cilium’s eBPF datapath, in my case) do not forward
multicast or arbitrary IPv6 router advertisements from the physical LAN into the pod
network. So any pod that needs to talk Matter/Thread has to leave the pod network
overlay entirely and use the node’s real network stack.

Architecture

┌────────────────────────────────────────────────────────────────┐
│ Physical LAN (192.168.0.0/24)                                  │
│                                                                │
│   OTBR (SLZB-MR4U) 192.168.0.169                               │
│     - advertises _meshcop._udp / _trel._udp via mDNS           │
│     - advertises Thread OMR prefix via IPv6 RA + RIO           │
│     - REST API on :8080 (device-specific! default is :8081)    │
│                                                                │
│   ┌──────────────────────── Kubernetes node (Talos) ─────────┐ │
│   │  end0 (physical NIC, e.g. 192.168.0.161)                 │ │
│   │                                                          │ │
│   │  ┌─── hostNetwork: true pods (share end0 directly) ───┐  │ │
│   │  │  homeassistant   (Thread integration)              │  │ │
│   │  │  matterjs-server (Matter controller, :5580)        │  │ │
│   │  └────────────────────────────────────────────────────┘  │ │
│   │                                                          │ │
│   │  cilium_host / cilium_net / cilium_vxlan / lxc*          │ │
│   │  (Cilium's pod-network overlay - NOT used by the         │ │
│   │   pods above, and NOT reachable by mDNS from the LAN)    │ │
│   └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘

The challenges I had to overcome

These are the problems I ran into, in the order of the detailed sections below:

  1. mDNS never reached my pods. Cilium’s pod network does not carry multicast from the
    LAN, so neither Home Assistant nor matter.js could see the Border Router or any Matter
    device. Fixed with hostNetwork: true.
  2. Kubernetes refused to create the hostNetwork pods. Pod Security Admission
    (baseline) rejects host networking, so the Deployments sat at 0/1 forever. Fixed by
    labelling the namespaces privileged.
  3. Devices were discovered but “unreachable”, so commissioning failed. The nodes
    silently ignored the IPv6 route into the Thread mesh that the OTBR advertises
    (accept_ra_rt_info_max_plen defaults to 0). Fixed with a Talos sysctl patch on
    every node.
  4. The matter.js WebSocket port was not reachable through the Cilium Gateway. An
    HTTPRoute for the dashboard is not enough; the raw TCP port needs its own Gateway
    listener and TCPRoute.
  5. matter.js probed the wrong OTBR REST port. My OTBR serves its REST API on 8080
    instead of 8081, and the option to change that only exists in a nightly image
    (THREAD_REST_PROBE_PORT), so I had to pin a specific nightly tag.

Challenge 1: mDNS never reaches the pod network

Symptom: Home Assistant’s Thread integration (or matter.js) never sees the Border
Router or any Matter device at all - no error, it just times out.

Root cause: By default, pods run on Cilium’s pod-network overlay (a veth pair into
cilium_host, forwarded via eBPF/VXLAN). Cilium does not bridge multicast traffic from
the physical LAN into this overlay - it’s simply a different, encapsulated network. mDNS
(and the underlying IGMP/MLD group membership it relies on) never crosses that boundary.

I confirmed this empirically: tcpdump -i end0 on a Talos node’s real network
namespace shows constant _trel._udp.local and _meshcop._udp.local announcements from
the OTBR - but a pod on the regular overlay network never sees a single one of these
packets. Once I sniffed on the node’s own physical interface instead of a pod’s
overlay interface, the underlying traffic was clearly present. Meaning: the problem was
“pod-network vs. host-network”, not the LAN/OTBR itself.

Fix: Any pod that needs mDNS/Matter reachability must use hostNetwork: true, so it
shares the node’s real network namespace (and its end0 interface) directly instead of
Cilium’s overlay:

spec:
  template:
    spec:
      hostNetwork: true
      dnsPolicy: ClusterFirstWithHostNet   # keep cluster DNS working despite hostNetwork
      containers:
        - name: homeassistant
          # ...

I applied this to both the Home Assistant pod and the matter.js server pod - both
need to see the OTBR’s mDNS traffic directly.

dnsPolicy: ClusterFirstWithHostNet matters: with plain hostNetwork: true and the
default dnsPolicy: ClusterFirst, the pod would use the node’s /etc/resolv.conf
instead of cluster DNS, breaking Service name resolution (e.g. talking to other pods by
*.svc.cluster.local).

Challenge 2: Pod Security Admission blocks hostNetwork

Symptom:

Error creating: pods "homeassistant-xxxxx" is forbidden: violates PodSecurity
"baseline:latest": host namespaces (hostNetwork=true)

The Deployment shows 0/1 ready forever, and kubectl describe replicaset shows
FailedCreate events - the pod is never even scheduled.

Root cause: If your cluster enforces the Kubernetes-default baseline (or
restricted) Pod Security Admission level (no explicit label on the namespace), any pod
requesting hostNetwork: true is rejected outright by the API server before it’s even
scheduled.

Fix: Explicitly label the namespace to allow privileged pods:

apiVersion: v1
kind: Namespace
metadata:
  name: homeassistant
  labels:
    pod-security.kubernetes.io/enforce: privileged

You’ll still see a warning (not an error) on every kubectl apply after this,
because the pod also doesn’t set restricted-level fields like
securityContext.capabilities.drop: ["ALL"] or runAsNonRoot: true:

Warning: would violate PodSecurity "restricted:latest": host namespaces (hostNetwork=true), ...

That’s expected and harmless - it’s just Kubernetes telling you this pod wouldn’t pass a
stricter level than the one you actually enforce. As long as your namespace enforces
privileged (not restricted), the pod is admitted fine.

Challenge 3: mDNS-resolved addresses are “unreachable” (the big one)

Symptom: This is the one that will burn the most time. Everything looks like it’s
working - mDNS discovery succeeds, matter.js even resolves the Thread device’s
operational IPv6 address - but commissioning fails:

CommissioningClient Operational address for undefined set to udp://[fd4f:5786:fb03:1:8786:fe0c:19ce:7fc7]:5540
WARN  Address udp://[fd4f:...]:5540 unreachable for known-address-...
ERROR Commission failed: Could not connect to device

or, when trying to discover a device fresh:

ERROR discovery of node with discriminator 2 failed: No commissionable device was discovered

Root cause: Thread devices live inside a mesh network with its own IPv6 addressing
(a fdXX:.../64 “OMR” - Off-Mesh-Routable - prefix). The OTBR is the gateway into that
mesh, and it advertises this to the LAN via an IPv6 Router Advertisement carrying a
Route Information Option (RIO, RFC 4191)
- essentially “send traffic for this /64 to
me”. Linux hosts learn arbitrary on-link prefixes from RAs by default, but RIO-learned
routes are opt-in
via the sysctl net.ipv6.conf.<iface>.accept_ra_rt_info_max_plen,
which defaults to 0 on most distributions (including Talos Linux) - meaning “ignore
all RIOs, no matter how specific”.

So every node in the cluster resolves the Thread device’s mesh-local IPv6 address just
fine via mDNS (mDNS just needs multicast, which Challenge 1 already fixed) - but the
kernel has literally no route to get a packet there, because it silently ignored the
RIO from the OTBR that would have told it how.

You can check this directly on the node:

$ sysctl net.ipv6.conf.end0.accept_ra_rt_info_max_plen
net.ipv6.conf.end0.accept_ra_rt_info_max_plen = 0
$ ip -6 route show
2a02:.../64 dev end0 proto kernel ...
fe80::/64 dev end0 proto kernel ...
default via fe80::... dev end0 proto ra ...
# notice: no fdXX:.../64 route to the Thread mesh at all

Fix: Raise accept_ra_rt_info_max_plen to (at least) 64 on the physical interface
of every node, since Thread OMR prefixes are always /64. On Talos this is a
machine-config sysctl patch:

# thread-route-info.yaml
machine:
  sysctls:
    net.ipv6.conf.end0.accept_ra_rt_info_max_plen: "64"

Applied live, without a reboot, via:

talosctl patch machineconfig `
  --nodes <all-node-ips> `
  --patch @thread-route-info.yaml

On non-Talos Linux nodes the equivalent is:

sysctl -w net.ipv6.conf.end0.accept_ra_rt_info_max_plen=64
# and persist it, e.g. in /etc/sysctl.d/99-thread.conf

Substitute your actual physical NIC name for end0 (I verified with ip -o -4 addr show inside a hostNetwork: true debug pod that it’s consistently end0 across all of
my nodes - yours may be eth0, enp1s0, etc.).

After this, the previously-unreachable operational address in the logs above becomes
reachable, and commissioning succeeds.

Challenge 4: Cilium Gateway API needs a dedicated listener/route per raw TCP port

This isn’t Matter/Thread-specific, but it trips people up when exposing the matter.js
server’s dashboard and its WebSocket control port through the same Cilium Gateway.

Symptom: http://matterjs.example/ (the web dashboard) works fine, but the app then
asks you to point it at ws://matterjs.example:5580 and that fails to connect - even
though the Service/Deployment are healthy.

Root cause: An HTTPRoute only routes HTTP(S) traffic arriving on a Gateway listener
that’s already bound to port 80/443. It does not automatically make Cilium listen on
an arbitrary additional TCP port (like matter.js’s WebSocket port 5580) just because a
Service exposes it - the Gateway has to be explicitly told to listen there.

Fix: Add a dedicated TCP listener to the shared Gateway for each raw TCP port you
need, and a matching TCPRoute (not HTTPRoute) that references it via sectionName:

# on the shared Gateway
apiVersion: gateway.networking.k8s.io/v1beta1
kind: Gateway
metadata:
  name: my-gateway
spec:
  listeners:
    - protocol: HTTP
      port: 80
      name: web-gw
      allowedRoutes: { namespaces: { from: All } }
    - protocol: TCP
      port: 5580
      name: matterjs-ws
      allowedRoutes: { namespaces: { from: All } }
# in the app's namespace
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
  name: matterjs-server-ws-route
spec:
  parentRefs:
    - name: my-gateway
      namespace: default
      sectionName: matterjs-ws   # must match the listener name above
  rules:
    - backendRefs:
        - name: matterjs-server
          port: 5580

Both the HTTPRoute (dashboard, port 80) and the TCPRoute (raw WebSocket, port 5580)
point at the same backend Service/port - they just arrive through different Gateway
listeners, since one needs HTTP-aware routing (virtual hosts) and the other is a plain
TCP passthrough.

Don’t forget a ReferenceGrant in the app’s namespace that authorizes both route
types if the Gateway lives in a different namespace:

apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
  name: allow-gateway-to-matterjs-server
spec:
  from:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      namespace: matterjs-server
    - group: gateway.networking.k8s.io
      kind: TCPRoute
      namespace: matterjs-server
  to:
    - group: ""
      kind: Service
      name: matterjs-server

Challenge 5: Your OTBR’s REST API may not be on the “standard” port

This one is specific to whichever OTBR hardware/firmware you use, but is worth checking
early since it produces confusing, easy-to-misdiagnose log noise.

Symptom: matter.js server logs repeated OtbrRestProbe ... rest_unreachable for a
Border Router that mDNS did discover correctly:

OtbrRestProbe probe MISS http://192.168.0.169:8081 /node rest_unreachable (GET /node failed: fetch failed)

Root cause: matter.js server’s default assumption for the OTBR’s REST diagnostics
API is TCP port 8081 (matching the reference OpenThread Border Router image). My
SLZB-MR4U (a Zigbee/Thread USB-to-Ethernet dongle running its own OTBR firmware) exposes
that same API on port 8080 instead, with 8081 closed entirely. I confirmed this
with a quick port scan from a hostNetwork debug pod:

$ nmap -p 80,8080,8081 192.168.0.169
80/tcp   open   http
8080/tcp open   http-proxy   # <- OTBR REST API actually lives here
8081/tcp closed blackice-icecap

Important: unlike Challenges 1-3, this one does not block Matter commissioning
or basic Border Router discovery/registration - get_thread_border_routers will still
correctly list your OTBR (network name, extended PAN ID, addresses, etc.) via mDNS alone.
It only degrades the diagnostics feature (network topology graphs, node/route
tables), which silently falls back to slower CoAP-only queries when the REST probe
fails. Easy to mistake for a bigger problem than it is.

Fix: Point matter.js server at the correct port via THREAD_REST_PROBE_PORT:

env:
  - name: THREAD_REST_PROBE_PORT
    value: "8080"

At the time of writing, this option only exists in nightly builds
(ghcr.io/matter-js/matterjs-server:1.4.1-alpha.6-... or later) - it hadn’t yet been
included in a numbered :stable release. Check the
CLI docs for
whether it’s landed in :stable by the time you read this; if not, pin to the earliest
nightly tag that has it rather than the constantly-moving :dev tag, so your deployment
stays reproducible.

Verification checklist

Once all of the above is applied, verify end-to-end from inside the cluster:

  1. mDNS reaches the node:

    $ tcpdump -i end0 -n udp port 5353
    # should show periodic _trel._udp.local / _meshcop._udp.local from the OTBR's IP
    
  2. The route into the Thread mesh exists:

    $ ip -6 route show | grep -v fe80
    # should show an fdXX:.../64 route via the physical interface, not just the default route
    
  3. matter.js server sees the Border Router: open its WebSocket API and call
    get_thread_border_routers - sources should include "meshcop" and the entry
    should have your network’s extendedPanIdHex/networkName.

  4. Commissioning works: a fresh Thread device should commission successfully instead
    of failing with discovery ... failed: No commissionable device was discovered or
    Could not connect to device.

Summary of changes, per component

Component Change Why
Home Assistant Deployment hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet mDNS multicast must reach the pod directly
Home Assistant Namespace pod-security.kubernetes.io/enforce: privileged label admission would otherwise reject hostNetwork
matter.js server Deployment same hostNetwork/dnsPolicy/namespace label as above same reasons
matter.js server Deployment THREAD_REST_PROBE_PORT env var (device-specific) my OTBR’s REST API isn’t on the matter.js default port
Talos machine config (all nodes) net.ipv6.conf.<physical-nic>.accept_ra_rt_info_max_plen: "64" kernel must accept RFC 4191 routes into the Thread mesh
Cilium Gateway extra TCP listener for matter.js’s WebSocket port Gateway API needs an explicit listener per raw TCP port
matter.js server namespace TCPRoute + ReferenceGrant for that listener routes the WebSocket port through the new listener

None of this is exotic once you know what’s happening - but every symptom above looks
like “it’s just broken” until you realize you’re fighting three independent and
unrelated networking layers (pod overlay multicast, IPv6 RA route acceptance, and
Gateway API per-port listeners) at once.