Docker
More Raspberry Pi Projects
Project 5 — Docker and Containers
Why Docker on a Raspberry Pi?
In all the previous projects you installed software directly on the Pi's operating system — apt packages, config files, services all mixed together on the same filesystem. That works, but it has drawbacks: software conflicts between projects, difficult upgrades, and a Pi that becomes fragile and hard to reproduce if you ever need to rebuild it.
Docker solves this by packaging each application — along with all its dependencies, libraries, and config — into a self-contained container. Containers run in isolation from each other and from the host OS. Upgrading one container doesn't affect another. Deleting a container removes every trace of it. And if you ever need to rebuild your Pi, you can re-launch all your services from a single YAML file in seconds.
Core Concepts
docker pull.
Analogy: a cake recipe — the instructions, not the cake itself.
-p 8080:80) connects a port on the Pi's network interface to a port inside the container, making the app reachable from your browser.
Analogy: call forwarding — external calls to 8080 are forwarded to the container's port 80.
compose.yaml file and launch everything with one command.
Analogy: a stage manager — one cue launches the whole show.
What You Will Need
Step 1 — Install Docker
Use Docker's official install script — it detects the Pi's ARM architecture and installs the correct packages automatically:
pi@raspberrypi:~$ curl -fsSL https://get.docker.com -o get-docker.sh pi@raspberrypi:~$ sudo sh get-docker.sh
cat get-docker.sh | less before executing.
Add your user to the docker group
Without this, every Docker command needs sudo. Adding yourself to the docker group lets you run Docker as a regular user:
pi@raspberrypi:~$ sudo usermod -aG docker $USER pi@raspberrypi:~$ newgrp docker # apply group change without logging out
Verify the installation
pi@raspberrypi:~$ docker --version Docker version 25.0.3, build 4debf41 pi@raspberrypi:~$ docker run hello-world Hello from Docker! This message shows that your installation appears to be working correctly.
Enable Docker to start on boot
pi@raspberrypi:~$ sudo systemctl enable docker pi@raspberrypi:~$ sudo systemctl enable containerd
Step 2 — Organise Your Docker Projects
Before running any real containers, set up a clean directory structure. Keeping each project in its own folder with its own compose.yaml makes everything easy to find, back up, and manage:
pi@raspberrypi:~$ mkdir -p ~/docker/{pihole,jellyfin,nextcloud,grafana} pi@raspberrypi:~$ ls ~/docker/ grafana jellyfin nextcloud pihole
Each folder will contain one compose.yaml file and, optionally, any extra config files that container needs. Docker-managed volumes (for data that needs to persist) live in /var/lib/docker/volumes/ by default, or you can bind-mount specific host paths for easy access.
Step 3 — Essential Docker Commands
Learn these before running anything in production:
# ── Images ────────────────────────────────────────────────────── pi@raspberrypi:~$ docker pull nginx # download an image pi@raspberrypi:~$ docker images # list local images pi@raspberrypi:~$ docker rmi nginx # remove an image # ── Containers ────────────────────────────────────────────────── pi@raspberrypi:~$ docker ps # list running containers pi@raspberrypi:~$ docker ps -a # list all containers (inc. stopped) pi@raspberrypi:~$ docker stop container_name # stop a container gracefully pi@raspberrypi:~$ docker start container_name # start a stopped container pi@raspberrypi:~$ docker restart container_name # restart a container pi@raspberrypi:~$ docker rm container_name # delete a stopped container pi@raspberrypi:~$ docker logs container_name # view container output pi@raspberrypi:~$ docker logs -f container_name # follow logs live pi@raspberrypi:~$ docker exec -it container_name bash # open a shell inside a container pi@raspberrypi:~$ docker inspect container_name # full details in JSON # ── Volumes ───────────────────────────────────────────────────── pi@raspberrypi:~$ docker volume ls # list volumes pi@raspberrypi:~$ docker volume inspect vol_name # details + mount path # ── Cleanup ───────────────────────────────────────────────────── pi@raspberrypi:~$ docker system prune # remove all unused containers, images, networks pi@raspberrypi:~$ docker system df # show disk usage by Docker
Step 4 — Your First Docker Compose File
Docker Compose lets you define everything about a container (or group of containers) in a YAML file rather than a long command line. Here's a simple example — running Nginx as a web server:
pi@raspberrypi:~$ mkdir -p ~/docker/nginx pi@raspberrypi:~$ nano ~/docker/nginx/compose.yaml
services: nginx: image: nginx:latest container_name: nginx ports: - "8080:80" # host port 8080 → container port 80 volumes: - ./html:/usr/share/nginx/html:ro # serve files from local ./html folder restart: unless-stopped # restart automatically, unless manually stopped
pi@raspberrypi:~/docker/nginx$ docker compose up -d ✔ Container nginx Started pi@raspberrypi:~/docker/nginx$ docker compose ps NAME IMAGE STATUS PORTS nginx nginx:latest Up 2 minutes 0.0.0.0:8080->80/tcp
Open http://raspberrypi.local:8080 in a browser — you'll see the Nginx welcome page.
Key Compose commands (always run from the folder containing your compose.yaml):
docker compose up -d # start all services in the background docker compose down # stop and remove containers (volumes preserved) docker compose down -v # stop, remove containers AND volumes (data deleted!) docker compose pull # pull latest image versions docker compose logs -f # follow logs for all services docker compose restart # restart all services
Step 5 — Practical Example: Pi-hole in Docker
If you haven't already set up Pi-hole natively (Course 1, Project 2), Docker is an excellent way to run it — isolated, easy to update, and easy to remove.
pi@raspberrypi:~$ nano ~/docker/pihole/compose.yaml
services: pihole: image: pihole/pihole:latest container_name: pihole hostname: pihole ports: - "53:53/tcp" # DNS TCP - "53:53/udp" # DNS UDP - "8053:80/tcp" # web admin (use 8053 to avoid conflicts) environment: TZ: 'Europe/London' WEBPASSWORD: 'change_me' volumes: - 'pihole_data:/etc/pihole' - 'pihole_dnsmasq:/etc/dnsmasq.d' restart: unless-stopped volumes: pihole_data: pihole_dnsmasq:
pi@raspberrypi:~/docker/pihole$ docker compose up -d
Access the Pi-hole admin at http://raspberrypi.local:8053/admin. Log in with the password you set in the WEBPASSWORD environment variable.
Step 6 — Practical Example: Jellyfin in Docker
Running Jellyfin in Docker gives you clean updates and easy access to multiple media folders. Here's a Compose file that maps in your media drive:
services: jellyfin: image: jellyfin/jellyfin:latest container_name: jellyfin network_mode: host # lets Jellyfin use mDNS discovery on the LAN environment: - JELLYFIN_PublishedServerUrl=http://raspberrypi.local volumes: - 'jellyfin_config:/config' - 'jellyfin_cache:/cache' - /media/mediadrive/Movies:/media/movies:ro - /media/mediadrive/TV:/media/tv:ro restart: unless-stopped volumes: jellyfin_config: jellyfin_cache:
:ro suffix on volume mounts means read-only — Jellyfin can read your media files but cannot modify or delete them. Always use :ro for media mount points.
Step 7 — ARM64 and Image Compatibility
The Raspberry Pi uses an ARM processor, not the x86/amd64 architecture that most desktop and server software targets. This is the single biggest gotcha when running Docker on a Pi.
Docker Hub images that support ARM64 will say so in their tags page — look for linux/arm64 or linux/arm/v7 in the supported platforms list. Most major projects now publish ARM64 images, but some smaller or older images do not.
# Check which architectures an image supports: pi@raspberrypi:~$ docker buildx imagetools inspect jellyfin/jellyfin:latest | grep -A1 Platform Platform: linux/amd64 Platform: linux/arm64 Platform: linux/arm/v7 # Docker automatically pulls the right architecture for your Pi. # If you get "exec format error", the image has no ARM build.
When an image doesn't have an ARM version, your options are:
- Look for a community-maintained ARM fork on Docker Hub (search for the app name +
arm64) - Build the image yourself from source using
docker build - Use an alternative application that does support ARM
- Run the app natively (apt install) rather than in Docker
Step 8 — Keeping Containers Updated
When a new version of an image is released, update it like this:
pi@raspberrypi:~/docker/jellyfin$ docker compose pull # pull new images pi@raspberrypi:~/docker/jellyfin$ docker compose up -d # recreate containers with new images pi@raspberrypi:~/docker/jellyfin$ docker image prune -f # delete old images to free disk space
Automate updates with Watchtower (optional)
Watchtower is a container that watches your other containers and automatically pulls and restarts them when new images are released:
services: watchtower: image: containrrr/watchtower container_name: watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_CLEANUP=true # delete old images after updating - WATCHTOWER_SCHEDULE=0 0 4 * * * # run at 4am daily (cron format) - TZ=Europe/London restart: unless-stopped
More Containers Worth Running on a Pi
Troubleshooting
sudo ss -tlnp | grep :53. Either stop the conflicting service, or change the host-side port in your compose.yaml (e.g. change "53:53" to "5353:53" and update your router's DNS settings to match).docker logs container_name. Common causes: a missing required environment variable, a misconfigured volume path that doesn't exist on the host, or an image that requires more memory than is available. Fix the config in compose.yaml and run docker compose up -d again.docker ps — you should see something like 0.0.0.0:8080->80/tcp in the PORTS column. If it shows 80/tcp with no host port, the ports section is missing from your compose.yaml. If the mapping is there, confirm no firewall (ufw) is blocking the port: sudo ufw status.docker rm or docker compose down -v. Always mount volumes for databases, config, and media. To recover: docker compose down (not down -v) removes containers but preserves named volumes. Check your volumes still exist: docker volume ls.docker system df to see a breakdown. Safe cleanup commands: docker image prune -f (delete dangling images), docker container prune -f (delete stopped containers). For a full cleanup of everything unused: docker system prune -a — but this removes all images not currently in use, so you'll need to re-pull them when you next start those containers.ping 8.8.8.8. Then check from inside the container: docker exec container_name ping 8.8.8.8. For containers needing to talk to each other, put them on the same user-defined Docker network in compose.yaml: add a networks: section and reference it in each service. Containers on the same network can reach each other by service name.Quick Reference
| Task | Command |
|---|---|
| List running containers | docker ps |
| List all containers | docker ps -a |
| View container logs | docker logs -f container_name |
| Open shell in container | docker exec -it container_name bash |
| Start all services (Compose) | docker compose up -d |
| Stop all services (Compose) | docker compose down |
| View Compose service logs | docker compose logs -f |
| Pull latest images | docker compose pull |
| Recreate after pull | docker compose up -d --force-recreate |
| List images | docker images |
| Remove unused images | docker image prune -f |
| Show disk usage | docker system df |
| Full cleanup (unused) | docker system prune -a |
| List volumes | docker volume ls |
| Check image architecture | docker buildx imagetools inspect image:tag |
| Docker version | docker --version |
| Docker Hub | hub.docker.com |
| Compose file reference | docs.docker.com/compose/compose-file |