Flash a Raspberry Pi 4B with Ubuntu Server 26.04 LTS, pre-configured as an OpenThread Border Router (OTBR). An ESP32-C6 acts as the Thread Radio Co-Processor (RCP), connected via USB.
The same .env file drives four deployment paths:
| Command | Where it runs | How |
|---|---|---|
otbr flash |
Raspberry Pi 4B (SD card) | Ubuntu Server + cloud-init + snap |
otbr snap |
Any Ubuntu host (bare metal) | snap (live install) |
otbr docker |
Any Ubuntu host (bare metal) | Docker CE + nginx |
otbr vm x64 |
Incus VM or container (testing) | snap (no hardware needed) |
| Item | Notes |
|---|---|
| Raspberry Pi 4B | Pi 3B also works |
| ESP32-C6 development board | The Thread radio. Connected to the Pi via USB at first boot |
| microSD card | 8 GB minimum, 16 GB+ recommended |
| Ethernet cable | Recommended for first boot. Wi-Fi is supported as a fallback |
- Standard tools:
curl,xzcat,dd,lsblk,python3,snap,rsync - QEMU user-mode emulation (for the arm64 chroot step):
qemu-user-binfmt
Install any missing packages with:
sudo apt-get install qemu-user-binfmt rsyncgit clone https://github.com/farscapian/otbr-commissioner-stacks.git
cd otbr-commissioner-stacks
source ./otbr.shThe source command loads the otbr shell function into your current
terminal. To make it permanent, add this line to your ~/.bashrc:
source /path/to/otbr-commissioner-stacks/otbr.shEnv files live in ~/.otbr/env/. Copy an example and fill in your values:
# Name it after your machine (hostname-specific, takes priority):
cp .env.example ~/.otbr/env/$(hostname).env
nano ~/.otbr/env/$(hostname).env
# Or use the generic fallback name:
cp .env.example ~/.otbr/env/.envThe two required values are:
-
THREAD_DATASET_TLV— your Thread network's Active Operational Dataset (a hex string). If you don't have one yet, generate a new one:# On any machine with the OTBR snap or ot-ctl installed: snap run openthread-border-router.ot-ctl dataset init new snap run openthread-border-router.ot-ctl dataset commit active snap run openthread-border-router.ot-ctl dataset active -x # Copy the hex output into THREAD_DATASET_TLV in your .env
Or if you already have an existing Thread commissioner, export it:
ot-ctl dataset active -x
-
SSH_PUBKEY— your SSH public key (the contents of~/.ssh/id_ed25519.pubor similar). This is injected into the Pi image so you can SSH in without a password.
Everything else in .env has sensible defaults. Wi-Fi credentials are optional —
if you're using Ethernet, leave WIFI_SSID and WIFI_PASSWORD blank.
The flash script checks that your ~/.ssh/config has an entry for the Pi's
hostname (default: otbr-raspi4) so you can reach it by name after boot.
If one doesn't exist, the script will offer to create it. You can also add
it manually:
# ~/.ssh/config
Host otbr-raspi4
User ubuntu
# HostName 192.168.x.y # optional if you use mDNS / .local hostname
Insert the microSD card into your Linux host and identify its device path
(lsblk or dmesg | tail after inserting). It will look like /dev/sdb
or /dev/mmcblk0 — never /dev/sda (that's usually your main drive).
# Standard flash — asks for confirmation before writing
otbr flash /dev/sdX
# Force a full reflash even if Ubuntu is already on the card
otbr flash -f /dev/sdX
# Skip the confirmation prompt (useful for scripting)
otbr flash -y /dev/sdX
# Use a different env file
otbr flash --env-file=pangolin.env /dev/sdX
# Set a custom hostname for this device
otbr flash --hostname=otbr-kitchen /dev/sdXSmart re-flash: If Ubuntu Server is already on the card (detected by the
system-bootpartition label),otbr flashskips the image download andddstep entirely and only rewrites the cloud-init config. This is much faster when you just changed a config value.
- Downloads
ubuntu-26.04-preinstalled-server-arm64+raspi.img.xzfrom Canonical (cached incache/ubuntu/server/— only downloaded once). - Verifies the SHA-256 of the downloaded image.
- Expands the root partition to fill the SD card.
- Runs an arm64 chroot to pre-install packages (
git,cmake,python3, etc.) and pre-load the ESP-IDF toolchain so the Pi doesn't have to download them at boot. - Caches arm64 snaps (
openthread-border-router,chip-tool) and copies them to the SD card so first boot can install offline. - Writes a cloud-init config into the
system-bootpartition that sets up networking, installs and configures the OTBR snap, and seeds the Thread dataset.
Insert the SD card into the Pi, connect the ESP32-C6 via USB, and power on.
First boot takes 5–15 minutes depending on internet speed (the Pi may still need to pull some packages). You can watch progress over SSH:
# Tail all logs in real time (journald + firstboot log)
otbr logs -f otbr-raspi4
# Or SSH in and check the firstboot log directly
ssh otbr-raspi4
sudo tail -f /var/log/otbr-firstboot.log- Networking — eth0 comes up via DHCP (preferred). If Wi-Fi credentials were set, wlan0 is configured as a fallback.
- RCP firmware —
otbr-rcp-update.shwaits for the ESP32-C6 to enumerate, then fetches the latest ESP-IDF release, builds theot_rcpfirmware, and flashes it to the ESP32-C6 if the firmware has changed. Identical firmware is detected by SHA-256 and skipped. - Snap install —
openthread-border-routeris installed from the pre-loaded copy on the SD card (no store download needed).chip-toolis also installed for Matter commissioning. - OTBR configuration — snap interfaces are connected, the radio URL is set, the backbone interface is configured, and the service is started.
- Thread dataset — the TLV from your
.envis committed and the Thread interface is brought up. - Firewall — UFW is enabled. SSH is allowed from the CIDRs in
SSH_MGMT_CIDRS(or from anywhere if that's empty).
After first boot, otbr-rcp-update.service runs on every subsequent boot to
keep the RCP firmware up to date. A weekly timer triggers a reboot to check for
new firmware.
ssh otbr-raspi4
# Check Thread state (should be leader, router, or child)
snap run openthread-border-router.ot-ctl state
# Check active dataset
snap run openthread-border-router.ot-ctl dataset active -x
# Check snap service status
snap services openthread-border-router# Tail logs from any otbr-managed device
otbr logs -f otbr-raspi4
# View last-boot logs (static)
otbr logs otbr-raspi4
# Reboot the device
otbr restart otbr-raspi4
# Graceful shutdown
otbr shutdown otbr-raspi4otbr vm x64 provisions an Incus VM with the same OTBR first-boot
sequence — no Raspberry Pi or ESP32-C6 needed. A simulated RCP (ot-rcp) is
built from OpenThread source and used instead of the physical ESP32-C6.
Prerequisites: Incus installed, current user in the incus group.
otbr vm x64 # VM (default), instance name: otbr-test-x64
otbr vm x64 --container # system container (faster), name: otbr-test-ct
otbr vm arm64 # arm64 VM (QEMU-emulated, slower)The first run clones and builds the OpenThread simulator (~5 min). Subsequent
runs reuse the cached binary at cache/ot-rcp-sim/ot-rcp.
To tear down an instance:
incus delete otbr-test-x64 --forcesudo ./tests/test_otbr_vm.sh # full test, x64 VM
sudo ./tests/test_otbr_vm.sh --no-peer-test # skip neighbor exchange (T6)Six tests verify: Ubuntu 26.04 running, OTBR snap installed, all services
active, Thread state is leader/router/child, dataset TLV committed, and
neighbor table has ≥1 entry (T6, requires ot-cli binary).
Installs and configures the openthread-border-router snap on any Ubuntu host
with a USB Thread radio attached. Runs as your normal user — sudo is invoked
internally only where needed.
Supported radios (auto-detected):
- ESP32-C6 (USB vendor
303a) on/dev/ttyACM0 - Sonoff Dongle-E / CP210x (Silicon Labs
10c4:ea60) on/dev/ttyUSB0
otbr snapThe script detects the radio, verifies its Spinel firmware (flashing the
ESP32-C6 if needed), installs and configures the snap, and commits the Thread
dataset. Optional .env variables:
| Variable | Default | Purpose |
|---|---|---|
INFRA_IF |
auto (default route) | Backbone network interface |
THREAD_IF |
wpan0 |
Thread virtual interface name |
Installs Docker CE, pulls openthread/otbr, writes a stable udev symlink for
the USB dongle, and sets up nginx as a reverse proxy. Run as root.
otbr dockerExposes: REST API on :8080, web UI on :8088.
Required .env variables (find values with udevadm info /dev/ttyACM0):
| Variable | Purpose |
|---|---|
DONGLE_VENDOR |
USB vendor ID |
DONGLE_PRODUCT |
USB product ID |
DONGLE_SERIAL |
USB serial string (unique per device) |
Optional:
| Variable | Default | Purpose |
|---|---|---|
DONGLE_SYMLINK |
ttyTHREAD |
Symlink name under /dev/ |
BAUD_RATE |
460800 |
Serial baud rate |
All variables are read from your .env file. See .env.example for the full
annotated list. Key variables:
| Variable | Used by | Purpose |
|---|---|---|
THREAD_DATASET_TLV |
all | Thread Active Operational Dataset (hex). Required. |
SSH_PUBKEY |
flash, vm | SSH public key injected into the image |
OTBR_SNAP_CHANNEL |
flash, snap, vm | Snap channel (default: latest/edge) |
CHIP_TOOL_SNAP_CHANNEL |
flash | chip-tool snap channel (default: latest/stable) |
WIFI_SSID / WIFI_PASSWORD |
flash | Wi-Fi credentials (optional; eth0 is preferred) |
OTBR_HOSTNAME |
flash | Device hostname (default: otbr-raspi4) |
SSH_MGMT_CIDRS |
flash | Space-separated CIDRs for SSH access via UFW |
HTTP_PROXY |
all | Optional HTTP proxy (e.g. http://squid.local:3128) |
INFRA_IF |
snap | Backbone interface (default: auto-detected) |
DONGLE_VENDOR/PRODUCT/SERIAL |
docker | USB dongle identification |
otbr.sh # Shell function — source this to get the otbr command
flash-piotbr.sh # Raspberry Pi SD card flasher (called by otbr flash)
provision_incus.sh # Incus VM/container provisioner (called by otbr vm)
otbr-snap-setup.sh # Bare-metal snap provisioner (called by otbr snap)
otbr-docker-setup.sh # Bare-metal Docker provisioner (called by otbr docker)
scripts/
flash_rcp.sh # ESP32-C6 RCP firmware builder and flasher
verify_rcp.py # Spinel probe (checks if RCP firmware responds correctly)
tests/
test_otbr_vm.sh # Integration test suite for Incus provisioning
.env.example # Annotated template — copy to ~/.otbr/env/ and edit
pangolin.env # Example env for a specific host (copy to ~/.otbr/env/ and adapt)
~/.otbr/ # Runtime data (created by setup.sh / on first run)
env/ # Env files: .env or <hostname>.env
cache/ # Downloaded images, snaps, firmware
logs/ # Per-device and per-session log files
artifacts/ # Generated cloud-init payloads, pyspinel venv