I don’t want ESP-IDF on my host system. I don’t want to deal with Python virtual environments, toolchain paths, or USB serial permissions. I want to type ./scripts/build.sh and get a working firmware image.
Docker makes this possible. The official espressif/idf image has everything: the ESP-IDF framework, the Xtensa compiler, Python dependencies, and build tools. You can build, flash, and monitor ESP32 firmware without installing anything except Docker.
The Build Script
Here’s the entire build script:
#!/bin/bash
docker run --rm \
-v "$PWD":/project \
-w /project \
espressif/idf \
idf.py build
That’s it. Mount your project directory, run idf.py build inside the container, get a firmware binary. No installation, no PATH setup, no dependency hell.
The first time you run it, Docker pulls the espressif/idf image (about 2GB). Every subsequent build uses the cached image. Build times are identical to native builds because the compiler runs at full speed in the container.
Flashing Needs Device Access
Flashing is slightly more complex because you need to pass through the USB serial device:
#!/bin/bash
docker run --rm \
-v "$PWD":/project \
-w /project \
--device=/dev/ttyUSB0 \
espressif/idf \
idf.py -p /dev/ttyUSB0 flash
The --device=/dev/ttyUSB0 flag gives the container access to your serial port. If your device shows up as /dev/ttyACM0 or something else, adjust accordingly.
You might need to add your user to the dialout group to access serial ports without sudo:
sudo usermod -a -G dialout $USER
# Log out and back in for the change to take effect
Serial Monitoring
Monitoring serial output works the same way:
#!/bin/bash
docker run -it --rm \
-v "$PWD":/project \
-w /project \
--device=/dev/ttyUSB0 \
espressif/idf \
idf.py -p /dev/ttyUSB0 monitor
The -it flags make it interactive so you can see output in real time and use Ctrl+C to exit.
One gotcha: if you’re running a monitor session in a container, you can’t flash from another container (or the host) at the same time. The serial port can only be opened by one process. Always kill the monitor before flashing.
Killing Stale Monitors
Sometimes a Docker monitor session gets orphaned and holds the serial port. I added a helper to the flash script to kill any running monitors:
#!/bin/bash
# Kill any running monitor sessions first
pkill -f "idf.py.*monitor"
pkill -f "idf_monitor"
pkill -f "esp_idf_monitor"
docker ps -q --filter ancestor=espressif/idf | xargs -r docker stop
# Then flash
docker run --rm \
-v "$PWD":/project \
-w /project \
--device=/dev/ttyUSB0 \
espressif/idf \
idf.py -p /dev/ttyUSB0 flash
The pkill commands catch any native monitors, and the docker ps line stops any running IDF containers that might be holding the port.
Configuration with Menuconfig
ESP-IDF uses Kconfig for configuration. The menuconfig interface needs a terminal, so you need the -it flags:
#!/bin/bash
docker run -it --rm \
-v "$PWD":/project \
-w /project \
espressif/idf \
idf.py menuconfig
This opens the classic curses-based menu where you can configure WiFi credentials, GPIO pins, logging levels, and everything else. Changes are saved to sdkconfig in your project directory.
If you want to reset to defaults, just delete sdkconfig and rebuild. The build system will regenerate it from sdkconfig.defaults.
The Complete Workflow
Here’s my actual project structure:
camera_max/
├── main/ # Source code
├── scripts/
│ ├── build.sh # Docker build wrapper
│ ├── flash.sh # Docker flash wrapper
│ ├── monitor.sh # Docker serial monitor
│ └── menuconfig.sh # Docker menuconfig wrapper
├── sdkconfig.defaults # Default configuration
└── CMakeLists.txt # ESP-IDF project file
The workflow is:
- Edit code in
main/ - Run
./scripts/build.shto build - Run
./scripts/flash.shto flash - Run
./scripts/monitor.shto see logs
If I need to change configuration:
- Run
./scripts/menuconfig.sh - Save and exit
- Run
./scripts/build.shto rebuild with new config
Everything happens inside Docker containers. The host system only needs Docker and a text editor.
Why This Is Better Than Native
I’ve used ESP-IDF natively. It’s painful. You need:
- Python 3.8+ with specific packages
- CMake 3.16+
- Ninja build system
- The Xtensa cross-compiler
- ESP-IDF itself (git clone, submodule update, install.sh, export.sh)
- Correct $PATH every time you open a terminal
Miss one step and builds fail with cryptic errors. Upgrade your system Python and everything breaks. Switch projects with different ESP-IDF versions and you’re juggling virtual environments.
With Docker:
- Install Docker (one time, works forever)
- Run build script (it downloads the image automatically)
- Build firmware (works identically on every machine)
The Docker image pins ESP-IDF to a specific version. Everyone on the team gets the same build tools, the same Python packages, the same compiler version. No “works on my machine” bugs.
Caching Builds
Docker containers are stateless by default. Every run starts fresh. This means ccache doesn’t work across builds.
You can fix this by mounting a volume for the build directory:
docker run --rm \
-v "$PWD":/project \
-v "$PWD/build":/project/build \
-w /project \
espressif/idf \
idf.py build
But I don’t bother. Clean builds take 30-60 seconds on modern hardware. Incremental builds (same container session) work fine and are fast. The simplicity of stateless containers is worth the extra seconds on clean builds.
The Only Downside
The Docker approach has one limitation: you can’t use a debugger. JTAG debugging with OpenOCD requires device access and port forwarding that gets messy in Docker.
For 95% of ESP32 development, serial logging is enough. For the 5% where you need a debugger, I’d install ESP-IDF natively in a VM and use JTAG there.
But for build/flash/monitor workflow, Docker is perfect.
The Real Win
The real win isn’t avoiding installation. It’s reproducibility.
I can clone my repo on any machine with Docker and run ./scripts/build.sh. I don’t need to remember installation steps. I don’t need to check ESP-IDF versions. I don’t need to worry about Python environments or system dependencies.
It just works. Every time. On every machine.
That’s what development tools should do.