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.
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.
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.
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.
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:
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:
python, node, postgres, redis, nginx) — all multi-arch, all tested on ARMlscr.io/linuxserver/*) — community-maintained images with excellent ARM support for media servers, databases, and toolseclipse-mosquitto, grafana/grafana, prom/prometheus) — check the Docker Hub page for platform tagsWhen in doubt, search Docker Hub with the os/arch filter set to linux/arm64.
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.
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.
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 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
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.
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 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 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.
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.
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.
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.
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.
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.
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.