A reusable Docker setup that lets cargo run build, flash, and monitor a
physical ESP32-family board over USB from inside a container on Windows
(Docker Desktop's WSL2 backend) -- something no existing published image
or official tooling currently does end-to-end. Extracted from a working
setup built and verified against real hardware on a XIAO ESP32-S3 board + TRMNL ePaper display project, then generalized here for reuse across future ESP32+Rust projects with different chips/displays.
espressif/idf-rust
(official, maintained by the esp-rs team) already provides the hard
part: the Rust+xtensa/riscv toolchain, ESP-IDF, and even espflash and
web-flash pre-installed, per chip target. This repo doesn't reinvent
any of that -- it's a thin overlay (Dockerfile) plus two scripts that
solve the one thing left unsolved: getting a container running on
Windows real access to the board's USB serial port.
The official answer to that problem, used by esp-rs's own devcontainer
templates, is
esp-web-flash-server:
build in the container, then manually open a browser tab, click connect,
pick the COM port via the WebSerial permission popup, and click install
-- every single flash. Monitoring the device's serial output is a
completely separate step/tool on top of that (espflash monitor), since
web-flash only flashes. There's also a known open issue
(espflash#641) describing
espflash-over-WSL2-via-usbipd breakage, with no documented settled fix.
What's here instead: real USB device passthrough into the container, so
cargo run builds, flashes, and monitors in one command, the same as
it would on bare native hardware -- the only extra step is a one-time
per-Docker-Desktop-session PowerShell script.
- Copy into your project:
Dockerfile,scripts/attach-esp32-usb.ps1,scripts/container-flash-runner.sh, and merge thedevservice fromdocker-compose.snippet.ymlinto your owndocker-compose.yml. - Add
examples/cargo-config-runner-snippet.toml's content to your project's.cargo/config.toml, with your actual target triple. - In
docker-compose.yml, setIDF_RUST_TAG(see available tags) andCARGO_TARGET_<...>_RUNNERto match your chip. - Every Docker Desktop session, board plugged in:
.\scripts\attach-esp32-usb.ps1
docker compose exec dev bash, thencargo run-- builds, flashes, and monitors.
Docker Desktop's containers run inside a hidden WSL2 distro
(docker-desktop), separate from any user-visible Ubuntu/etc distro --
containers don't see host USB devices directly. usbipd-win bridges
that gap by attaching a USB device into a specific WSL2 distro's kernel instance.
attach-esp32-usb.ps1 automates the whole flow:
usbipd list # finds the board by VID (default 303a, Espressif)
usbipd bind --busid <BUSID> # one-time per physical USB port
usbipd attach --wsl docker-desktop --busid <BUSID> --auto-attach--auto-attach (not a plain attach) matters because most ESP32 boards
reset and re-enumerate partway through every flash (toggling into their
USB bootloader), which drops and re-establishes the USB connection -- a
one-shot attach wouldn't survive that. The attach command is a
foreground watcher; the script leaves it running in its own window for
the session. Re-run the script after a host reboot, a Docker Desktop
restart, or moving the board to a different USB port (new BUSID, needs
re-binding).
One more per-session step the script also does: the docker-desktop
WSL distro has no udev, so nothing ever triggers the kernel's usual
hotplug module autoload. The driver that turns the attached USB device
into a /dev/ttyACM0 (or /dev/ttyUSB0) node has to be loaded by hand:
wsl -d docker-desktop -- modprobe cdc_acmConfirmed empirically: without this, lsusb inside the distro shows the
board fine, but no /dev/tty* node ever appears, and espflash reports
"No serial ports could be detected". Once loaded, the module stays
loaded for the life of that WSL2 VM -- only a fresh Docker Desktop start
needs this again, not every flash or replug.
Boards with the chip's native USB (most ESP32-S2/S3/C3/C6 dev kits,
including the XIAO ESP32-S3 this was built against) enumerate as VID
303a (Espressif) and need the cdc_acm kernel module. Older/other
boards instead expose an external USB-serial bridge chip -- Silicon Labs
CP210x (VID 10c4) or WCH CH340 (VID 1a86) are the common ones. Pass
-Vid/-KernelModule to attach-esp32-usb.ps1 to match
(cp210x/ch341 respectively); check usbipd list with the board
plugged in if you're not sure which one you have.
The base image's default user (esp) has no password, so plain sudo
would fail auth outright -- hence the narrowly-scoped, passwordless
sudoers rule in the Dockerfile (only chmod 666 on
/dev/ttyACM*//dev/ttyUSB*, nothing broader). A dialout/uucp
group, the usual fix on a real Linux host, doesn't work here: that
group assignment is normally applied by a udev rule, and the
docker-desktop distro has no udev running to apply it. Every
re-enumeration resets the device node back to a restrictive default, so
scripts/container-flash-runner.sh re-chmods it before every flash
-- this is expected, permanent behavior of this setup, not a one-time
fix.
privileged: true lifts the container's device-cgroup allow-list, and
bind-mounting the host's live /dev (rather than a static devices:
entry naming one specific node) means whatever ttyACM*/ttyUSB* node
currently exists is visible without a container restart -- necessary
since the board's own mid-flash reset means the node can disappear and
reappear. This does give the dev container broad access to the WSL
distro's whole device namespace, not just the serial port -- a
deliberate trade-off, reasonable for a single-developer local dev
container, not a shared/production one.
espflashreports no serial ports found: checkusbipd listshows "Attached", then checkwsl -d docker-desktop -- lsusbshows the board, then check thecdc_acmmodprobe step ran.- Permission denied opening the serial port: the chmod in
container-flash-runner.shruns before every flash automatically -- if you're testing manually instead, runsudo chmod 666 /dev/ttyACM0yourself first. - Device disappeared mid-flash and never came back: check the
attach-esp32-usb.ps1-spawned watcher window is still open and didn't error out:--auto-attachneeds that window alive for the whole session. docker-desktopdistro not found: runwsl -l -vto confirm the actual running distro name (docker-desktop, notdocker-desktop-data) -- pass-WslDistributionif it's ever different on your machine.
MIT -- see LICENSE.