Nov 28, 2025
6 min read

Building an ESP32 Camera in Docker (So You Never Touch Your Host System)

The ESP-IDF toolchain is a nightmare to install, but the official Docker image makes it disappear completely.

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:

  1. Edit code in main/
  2. Run ./scripts/build.sh to build
  3. Run ./scripts/flash.sh to flash
  4. Run ./scripts/monitor.sh to see logs

If I need to change configuration:

  1. Run ./scripts/menuconfig.sh
  2. Save and exit
  3. Run ./scripts/build.sh to 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.

References