Docker

More Raspberry Pi Projects

Project 5 — Docker and Containers

What you will learn How Docker works, how to run containerised apps on your Pi, and how to manage multi-container stacks with Docker Compose
Difficulty Intermediate — the concepts take time to internalise, but the commands are straightforward
Time to complete 2 – 3 hours for installation and core concepts; ongoing as you run more containers
What you will run Practical examples: Pi-hole, Jellyfin, and a Nextcloud instance — all as Docker 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

Image A read-only template containing everything needed to run an application — OS layer, dependencies, and the app itself. Stored in a registry (like Docker Hub) and downloaded with docker pull. Analogy: a cake recipe — the instructions, not the cake itself.
Container A running instance of an image. You can run many containers from the same image simultaneously. Each has its own isolated filesystem, network, and processes. Analogy: the cake baked from the recipe — a live, running thing.
Volume A persistent storage location outside the container's filesystem. Data in a volume survives when the container is stopped, deleted, or updated. Always use volumes for databases and media. Analogy: an external hard drive plugged into a laptop.
Network Docker creates virtual networks that containers can join. Containers on the same Docker network can reach each other by name. Containers on different networks are isolated by default. Analogy: a private LAN inside your Pi that only containers can see.
Port mapping Containers have their own internal ports. Port mapping (-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.
Docker Compose A tool for defining and running multi-container applications. You describe all your containers, volumes, networks, and ports in a single compose.yaml file and launch everything with one command. Analogy: a stage manager — one cue launches the whole show.

What You Will Need

🍓 Raspberry Pi 4 2 GB RAM minimum; 4 GB recommended if you plan to run multiple containers simultaneously. Docker itself is lightweight, but each container has its own memory footprint.
💾 SSD (strongly recommended) Container images, volumes, and logs generate many small writes. Running Docker on a microSD card will shorten the card's lifespan significantly.
🐧 Raspberry Pi OS (64-bit) Use the 64-bit version of Raspberry Pi OS. Many Docker images (especially newer ones) are only published for arm64, not the older armhf 32-bit architecture.
🌐 Internet connection Docker pulls images from Docker Hub — the first pull of each image can be 100 MB to several GB depending on the application.
🔑 SSH access You'll run all Docker commands via SSH. Once containers are running, their web UIs are accessible from any browser on your local network.
🏗️ Folder structure Decide where to keep your Docker Compose files and volumes before you start. A tidy layout makes management much easier — see Step 2.

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
It's always good practice to read a script before running it with sudo. View it with 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
~/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.

If Pi-hole is already running natively on your Pi, don't run it again in Docker — you'll have a port conflict on port 53 (DNS). This example is for a fresh Pi-hole install only.
pi@raspberrypi:~$ nano ~/docker/pihole/compose.yaml
~/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:

~/docker/jellyfin/compose.yaml
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:
The :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:

~/docker/watchtower/compose.yaml
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
Watchtower updates containers automatically, which is convenient but risky for production services — a new image version might have breaking changes. Consider using Watchtower only for services where brief disruption and unexpected changes are acceptable, and updating critical services (databases, mail servers) manually after reading the release notes.

More Containers Worth Running on a Pi

Portainer A web UI for managing all your Docker containers, images, volumes, and networks visually — great for avoiding the command line once you're up and running. portainer/portainer-ce:latest
Home Assistant The home automation platform from Course 1 Project 5 is also available as a Docker container — useful for testing without touching your main install. ghcr.io/home-assistant/home-assistant:stable
Vaultwarden A lightweight, self-hosted Bitwarden-compatible password manager. One of the best reasons to run a Pi — keeps your passwords completely under your control. vaultwarden/server:latest
Uptime Kuma A beautiful self-hosted uptime monitor with a status page. Can monitor HTTP endpoints, TCP ports, DNS, and more — and alert via dozens of notification channels. louislam/uptime-kuma:latest
Immich A self-hosted Google Photos alternative with automatic phone backup, face recognition, and a polished mobile app. ARM64 supported. ghcr.io/immich-app/immich-server:release
Nginx Proxy Manager A web UI for managing reverse proxy rules and Let's Encrypt SSL certificates across all your containers — lets you serve multiple services on port 443 with nice domain names. jc21/nginx-proxy-manager:latest

Troubleshooting

⚠ "exec format error" when starting a container
The image was built for x86/amd64 and has no ARM64 version. Docker downloaded an incompatible architecture. Search Docker Hub for the image name to check its "OS/Arch" supported platforms. If there's no ARM64 image, look for a community ARM fork or install the app natively.
⚠ Port conflict — "bind: address already in use"
A service already running on the Pi (either natively or in another container) is using that port. Find what's using it: 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).
⚠ Container keeps restarting — "Restarting (1)" in docker ps
The container is crashing on startup. Check its logs immediately after it starts: 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.
⚠ Container works but is not accessible in a browser
A port mapping issue. Check the container is actually running and has the port mapped: 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.
⚠ Data is lost when I remove a container
You were storing data inside the container's filesystem rather than in a volume. Containers are ephemeral — any data not in a named volume or bind mount is deleted with 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 is using too much disk space
Old images, stopped containers, unused volumes, and build cache accumulate quickly. Run 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.
⚠ Container can't reach the internet or other containers
By default, containers get internet access via Docker's bridge network (NAT). If a container can't reach the internet, check the Pi has internet: 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

TaskCommand
List running containersdocker ps
List all containersdocker ps -a
View container logsdocker logs -f container_name
Open shell in containerdocker exec -it container_name bash
Start all services (Compose)docker compose up -d
Stop all services (Compose)docker compose down
View Compose service logsdocker compose logs -f
Pull latest imagesdocker compose pull
Recreate after pulldocker compose up -d --force-recreate
List imagesdocker images
Remove unused imagesdocker image prune -f
Show disk usagedocker system df
Full cleanup (unused)docker system prune -a
List volumesdocker volume ls
Check image architecturedocker buildx imagetools inspect image:tag
Docker versiondocker --version
Docker Hubhub.docker.com
Compose file referencedocs.docker.com/compose/compose-file