Part
5
  |  
Production Engineering
  |  
Chapter
19

Docker on the Pi

Most engineers assume containers are too heavy for a $35 board — then spend days debugging dependency conflicts that Docker eliminates in one line.
Reading Time
12
mins
BACK TO RASPBERRY PI MASTERCLASS

Every few months, someone tells me that Docker is too heavy for a Raspberry Pi. Too much overhead. Too much abstraction. The Pi only has 4 GB of RAM — you can't waste it on a container runtime. I nod politely, then ask how long they spent last week debugging a Python package that broke because apt upgraded a system library underneath it. The answer is usually "a couple of hours." Sometimes it's "two days." Occasionally it's a sheepish admission that they reimaged the SD card and started from scratch.

That reimaging moment is the exact problem Docker solves. The entire container runtime costs you roughly 50 MB of RAM. The reproducibility — knowing that your project runs identically on every Pi, on every SD card, on every deployment — is priceless.

The entire container runtime costs you roughly 50 MB of RAM. The reproducibility is priceless.

Installing Docker on the Pi

Docker's official install path for Raspberry Pi OS is a convenience script. It detects your architecture, adds the Docker apt repository, and installs the engine. No manual repository configuration required.

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

After installation, add your user to the docker group so you don't need sudo for every command:

sudo usermod -aG docker $USER

Log out and back in for the group change to take effect. Verify the installation:

docker run --rm hello-world

If that prints the "Hello from Docker!" message, the engine is running. On a Pi 4 or Pi 5, the install takes about two minutes. The engine itself uses roughly 30-50 MB of resident memory when idle — negligible on a 4 GB board.

Docker vs Podman

Podman is a daemonless alternative that some Linux purists prefer. It works on the Pi, but the ecosystem tooling — Compose files, GitHub Actions, community Dockerfiles — overwhelmingly targets Docker. Use Docker unless you have a specific organizational mandate for Podman.

Key takeaway

Docker installs in two minutes on a Pi and costs roughly 50 MB of RAM at idle. The "too heavy" argument hasn't been true since the Pi 3.

The ARM Architecture Caveat

Here is where new Pi users hit their first real wall with containers. You pull an image from Docker Hub, and it either fails to start or crashes immediately with exec format error. The reason: most Docker images on the hub are built for x86_64 (Intel/AMD). Your Pi runs ARM.

There are two ARM variants you'll encounter:

  • arm64 (aarch64) — the 64-bit ARM architecture. Pi 4 and Pi 5 running 64-bit Raspberry Pi OS use this. This is what you want.
  • armhf (armv7l) — the 32-bit ARM architecture. Older Pi OS installations or Pi 3 boards use this.

When pulling images, check for multi-architecture support. The major images — python, node, postgres, redis, mosquitto, grafana/grafana, nginx — all publish ARM variants. The ones that don't are typically niche or abandoned.

# Check available architectures for an image
docker manifest inspect python:3.12-slim | grep architecture

If an image doesn't support ARM, you have three options: find an alternative image that does, build from source using a Dockerfile, or use docker buildx with QEMU emulation (slow, but it works for builds).

I've seen this pattern where an engineer spends half a day debugging a container that crashes on startup, only to discover the image was built for x86. The exec format error message is distinctive — once you've seen it, you never forget it. But the first time, it looks like a mysterious crash. Always check the architecture before debugging anything else.

The most common ARM image sources:

  • Docker Official Images (python, node, postgres, redis, nginx) — all multi-arch, all tested on ARM
  • LinuxServer.io (lscr.io/linuxserver/*) — community-maintained images with excellent ARM support for media servers, databases, and tools
  • Project-specific images (eclipse-mosquitto, grafana/grafana, prom/prometheus) — check the Docker Hub page for platform tags

When in doubt, search Docker Hub with the os/arch filter set to linux/arm64.

Framework · The Container Principle · CP

If your project runs in Docker on ARM, it runs on any Pi, on any SD card, with zero dependency conflicts. The overhead is roughly 50 MB of RAM. The reproducibility — identical behavior from development to deployment — is what makes containers the default deployment strategy for every production Pi.

Writing a Dockerfile for a Pi Project

A Dockerfile for a Pi project follows the same rules as any Dockerfile, with one adjustment: your base image must support ARM. Here is a practical Dockerfile for a Python application that reads GPIO pins and processes camera frames with OpenCV:

# Stage 1: Build dependencies
FROM python:3.12-slim AS builder

WORKDIR /app
COPY requirements.txt .

# Install build dependencies for OpenCV and gpiozero
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      gcc \
      libopencv-dev \
      python3-dev && \
    pip install --no-cache-dir --prefix=/install -r requirements.txt && \
    apt-get purge -y gcc python3-dev && \
    apt-get autoremove -y && \
    rm -rf /var/lib/apt/lists/*

# Stage 2: Runtime image
FROM python:3.12-slim

WORKDIR /app

# Install only runtime libraries (no compiler)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      libopencv-core-dev \
      libopencv-imgproc-dev && \
    rm -rf /var/lib/apt/lists/*

# Copy installed Python packages from builder
COPY --from=builder /install /usr/local

COPY . .

CMD ["python", "app.py"]

And the requirements.txt:

flask==3.1.0
gpiozero==2.0.1
lgpio==0.6
opencv-python-headless==4.10.0.84

The multi-stage build matters. Stage one installs compilers and build tools to compile native extensions. Stage two only carries the compiled results and runtime libraries. On a Pi, where SD card space and image pull times are real constraints, this difference can be 400 MB versus 150 MB.

There is a subtlety here that's worth calling out: the --no-cache-dir flag on pip install prevents pip from storing downloaded wheels in the image layer. Without it, every package gets stored twice — once as a downloaded archive and once as an installed package. On a Pi with a 32 GB SD card, those extra 50-100 MB add up across multiple projects.

Another common mistake: using python:3.12 (the full image) instead of python:3.12-slim. The full image is 900+ MB and includes a C compiler, documentation, and utilities you'll never use at runtime. The slim variant is 130 MB. Always start with slim and add only the system packages your application actually needs.

On a Pi, where SD card space and image pull times are real constraints, multi-stage builds aren't optimization — they're necessity.

Build the image:

docker build -t pi-gpio-app:latest .

On a Pi 4, a first build with OpenCV takes 5-10 minutes. Subsequent builds hit the cache and finish in seconds. On a Pi 5, first builds drop to 3-5 minutes thanks to the faster processor.

Accessing GPIO from Inside a Container

Containers, by default, are isolated from host hardware. Your GPIO pins, I2C bus, SPI bus, and camera interface are all invisible from inside a standard container. You have two options for granting access.

Option 1: Privileged mode (simple, broad access)

docker run --privileged -d pi-gpio-app:latest

This gives the container full access to all host devices. It works, but it defeats much of the isolation that makes containers valuable. Use it for development. Avoid it in production.

Option 2: Device mapping (precise, production-appropriate)

docker run \
  --device /dev/gpiomem \
  --device /dev/i2c-1 \
  --device /dev/spidev0.0 \
  --device /dev/video0 \
  -d pi-gpio-app:latest

This maps only the specific devices your application needs. The container can read GPIO and the camera but has no access to the host filesystem, other USB devices, or kernel facilities it doesn't need.

The lgpio vs RPi.GPIO decision

The traditional RPi.GPIO library requires root access inside the container. The newer lgpio library (used by gpiozero on Pi 5) accesses GPIO through /dev/gpiochip* character devices, which work cleanly with --device mapping. If you're containerizing GPIO code, use gpiozero with the lgpio backend. It's the path of least resistance.

For the camera, also mount the video device:

docker run \
  --device /dev/video0 \
  --device /dev/gpiomem \
  -v /opt/vc:/opt/vc:ro \
  -d pi-gpio-app:latest

Docker Compose for Multi-Container Setups

Real Pi projects rarely run in isolation. A sensor-monitoring system needs the application, a message broker, and a dashboard. Running three separate docker run commands with the right flags, networking, and volume mounts every time the Pi reboots is exactly the kind of operational burden that burns reliability.

Docker Compose solves this with a single YAML file. Here is a production-grade docker-compose.yml for a Pi running a Flask GPIO API, Mosquitto for MQTT messaging, and Grafana for dashboards:

version: "3.8"

services:
  app:
    build: .
    restart: always
    devices:
      - /dev/gpiomem:/dev/gpiomem
      - /dev/video0:/dev/video0
    ports:
      - "5000:5000"
    environment:
      - MQTT_BROKER=mosquitto
      - FLASK_ENV=production
    depends_on:
      - mosquitto
    volumes:
      - app-data:/app/data
    networks:
      - pi-net

  mosquitto:
    image: eclipse-mosquitto:2
    restart: always
    ports:
      - "1883:1883"
    volumes:
      - mosquitto-data:/mosquitto/data
      - mosquitto-config:/mosquitto/config
    networks:
      - pi-net

  grafana:
    image: grafana/grafana:latest
    restart: always
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=changeme
    volumes:
      - grafana-data:/var/lib/grafana
    networks:
      - pi-net

volumes:
  app-data:
  mosquitto-data:
  mosquitto-config:
  grafana-data:

networks:
  pi-net:
    driver: bridge

Start everything with one command:

docker compose up -d

Stop everything:

docker compose down

Rebuild after code changes:

docker compose up -d --build app

The restart: always directive is critical for production Pi deployments. When the Pi reboots — whether from a power cycle, a kernel update, or a watchdog reset — Docker starts automatically, and every service comes back up in the right order. No cron jobs. No screen sessions. No hoping the user remembered to start things manually.

Key takeaway

Docker Compose turns a multi-service Pi deployment into a single YAML file. restart: always ensures everything survives reboots without cron jobs or manual intervention.

Volumes, Networking, and SD Card Survival

Volumes are how you persist data across container restarts and rebuilds. Without a volume, any data written inside the container disappears when the container stops. On a Pi, volumes also serve a second purpose: they help you manage SD card wear.

SD cards have limited write endurance. A database writing constantly to a containerized filesystem without a volume mount is writing to the container's overlay filesystem — which sits on the SD card. Named volumes at least centralize the write location so you can monitor it, and if you mount an external USB drive, you can redirect volume storage off the SD card entirely:

# Create a volume backed by an external drive
docker volume create --driver local \
  --opt type=none \
  --opt device=/mnt/usb-drive/docker-data \
  --opt o=bind \
  external-data

For networking, Docker's default bridge network handles most Pi use cases. Containers on the same bridge can reach each other by service name (that's why MQTT_BROKER=mosquitto works in the Compose file above — mosquitto resolves to the Mosquitto container's IP). For services that need to be reachable from other devices on your local network, expose ports with the ports directive.

If you need a container to behave as if it were directly on the host network — useful for mDNS discovery or when running services that other IoT devices need to find — use host networking:

docker run --network host -d pi-gpio-app:latest
Host networking and port conflicts

Host networking means the container shares the host's network namespace. If your container listens on port 5000 and something else on the Pi is already using port 5000, one of them fails. Use host networking sparingly — bridge networking with explicit port mapping is more predictable.

Practical Example: Containerizing a Flask GPIO API

Tying it all together. Here is a Flask application that exposes GPIO pins and a camera feed over HTTP, containerized and ready for production:

# app.py
from flask import Flask, jsonify, Response
from gpiozero import LED, Button, CPUTemperature
import cv2
import threading

app = Flask(__name__)

# GPIO setup
led = LED(17)
button = Button(27)
cpu = CPUTemperature()

# Camera setup
camera = cv2.VideoCapture(0)
camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
lock = threading.Lock()

@app.route("/health")
def health():
    return jsonify({"status": "ok", "cpu_temp": cpu.temperature})

@app.route("/led/on", methods=["POST"])
def led_on():
    led.on()
    return jsonify({"led": "on"})

@app.route("/led/off", methods=["POST"])
def led_off():
    led.off()
    return jsonify({"led": "off"})

@app.route("/button")
def button_status():
    return jsonify({"pressed": button.is_pressed})

def generate_frames():
    while True:
        with lock:
            success, frame = camera.read()
        if not success:
            break
        _, buffer = cv2.imencode(".jpg", frame)
        yield (
            b"--frame\r\n"
            b"Content-Type: image/jpeg\r\n\r\n"
            + buffer.tobytes()
            + b"\r\n"
        )

@app.route("/camera")
def video_feed():
    return Response(
        generate_frames(),
        mimetype="multipart/x-mixed-replace; boundary=frame",
    )

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Build and run:

docker compose up -d --build

Test:

curl http://localhost:5000/health
# {"cpu_temp": 48.3, "status": "ok"}

curl -X POST http://localhost:5000/led/on
# {"led": "on"}

Open http://<pi-ip>:3000 for Grafana. Open http://<pi-ip>:5000/camera for the live camera feed. Everything started with one command. Everything survives a reboot. Everything can be torn down and rebuilt from a single Compose file.

✕ Without Docker
  • Dependencies installed globally
  • apt upgrade can break your app
  • SD card reimage means full reinstall
  • Multi-service orchestration is manual
  • Works on my Pi but not yours
✓ With Docker
  • Dependencies locked in the image
  • Host packages can't interfere
  • Pull the image and run
  • docker compose up -d
  • Works on every Pi, every time

What to Do Monday Morning

Install Docker on your Pi

Run the two-line install script: curl -fsSL https://get.docker.com | sudo sh and sudo usermod -aG docker $USER. Log out, log back in, run docker run --rm hello-world. Two minutes.

Containerize one existing project

Pick the simplest project you have running on a Pi — a Python script, a Flask app, a sensor reader. Write a Dockerfile for it. Use python:3.12-slim as the base. Build it, run it, confirm it works. Don't try to containerize everything at once.

Add GPIO device mapping

Run your container with --device /dev/gpiomem instead of --privileged. Verify that GPIO access works with the minimal permission set. If you're on Pi 5 with lgpio, map /dev/gpiochip4 as well.

Write a docker-compose.yml

Even if your project is a single container today, write the Compose file. It's the foundation for adding a database, a broker, or a dashboard later — and it documents your deployment configuration in version control.

Set restart: always on every production container

Pull the power cable on your Pi, plug it back in, and verify every container comes back up without intervention. If anything stays down, you don't have a production deployment — you have a demo.

The trap was never that containers are too heavy. The trap is that running applications directly on the host feels simpler until the first dependency conflict, the first SD card reimage, or the first time you need to reproduce your setup on a second Pi. Docker costs you 50 MB of RAM and gives you reproducibility that no amount of careful apt management can match.

If you pull the power cable and your services don't come back up on their own, you don't have a production deployment — you have a demo.