Chapter 9 gives you the one-liner:
curl -fsSL https://get.docker.com | sh
That works. This walkthrough covers what it does, why the apt install docker.io you’ll find elsewhere is the wrong package, what adding yourself to the docker group really grants, and the maintenance routine that stops a homelab becoming a pile of unpatched containers.
Don’t use the distro package
Three things claim to be Docker on Ubuntu:
| Source | What you get |
|---|---|
apt install docker.io | Ubuntu’s own fork. Frequently a major version behind; no Compose v2. |
snap install docker | Confined. Bind-mounting host paths fails in ways that waste an afternoon. |
| Docker’s official apt repo | Current docker-ce, the Compose v2 plugin, security fixes on Docker’s schedule. |
Use the official repository. The get.docker.com script simply sets it up for you — here it is done explicitly, so you can see what lands on the machine:
sudo apt update
sudo apt install -y ca-certificates curl
# Docker's signing key, stored where apt expects a keyring
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# The repo, pinned to that key and your release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
Note signed-by= — it scopes that key to this repository only, so Docker’s key can’t vouch for packages from anywhere else.
The docker group, honestly
To run docker without sudo:
sudo usermod -aG docker $USER
newgrp docker # or log out and back in
Be clear about what you just did. The Docker daemon runs as root, and group membership grants unrestricted access to its socket. Anyone in the docker group can start a container that mounts / and read or write anything on the host:
# Don't run this. It is what group membership permits.
docker run -v /:/host -it alpine chroot /host
docker group membership is root access, without a password prompt or a sudo log entry. On a single-admin home server that’s a reasonable trade for convenience — you are root. On anything shared, use rootless mode instead.
Verify:
docker run --rm hello-world
docker compose version # v2 is a plugin: "docker compose", not "docker-compose"
Lay out the filesystem before you deploy anything
Decide now, or you’ll be hunting for volumes in six months:
sudo mkdir -p /srv/docker
sudo chown $USER:$USER /srv/docker
mkdir -p /srv/docker/vaultwarden
One directory per service, holding that service’s compose.yaml and its data. Self-contained, obvious to back up, trivial to move to another machine. When you reach the Restic walkthrough, /srv/docker is the single path you point it at.
A Compose file worth copying
Here’s Chapter 9’s Vaultwarden example, annotated and with the rough edges taken off:
# /srv/docker/vaultwarden/compose.yaml
services:
vaultwarden:
image: vaultwarden/server:1.34.1
container_name: vaultwarden
restart: unless-stopped
environment:
# Turn this off the moment your own account exists.
SIGNUPS_ALLOWED: "false"
DOMAIN: "https://vault.example.com"
volumes:
- ./data:/data
ports:
# Bind to loopback only. A reverse proxy fronts it; nothing else
# on the LAN can reach the port directly.
- "127.0.0.1:8080:80"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/alive"]
interval: 60s
timeout: 5s
retries: 3
Four differences from the version in the book, each deliberate:
version: '3' is gone. The top-level version key is obsolete in Compose v2 and emits a warning.
The image tag is pinned. :latest means a pull can silently move you across a breaking change, and you cannot tell what’s running or roll back to what worked. Pin the version, bump it on purpose.
The port binds to 127.0.0.1. Without that prefix, Docker publishes on 0.0.0.0 — every interface — and, because it writes its own iptables rules, ufw will not stop it. This surprises people badly. Check what you’re actually exposing:
sudo ss -tlnp | grep docker
WEBSOCKET_ENABLED is gone. It’s been the default since Vaultwarden 1.31 and is no longer read.
Start it:
cd /srv/docker/vaultwarden
docker compose up -d
docker compose logs -f # Ctrl-C stops following, not the container
Updating without breaking things
Pinned tags mean updates are a choice. The routine, per service:
cd /srv/docker/vaultwarden
# 1. back up the data directory first — always
# 2. edit compose.yaml, bump the tag
docker compose pull
docker compose up -d
docker compose logs --tail=50
Clear out accumulated layers occasionally. Know what you’re typing:
docker image prune -a # unused images. Safe.
docker system prune # + stopped containers, unused networks, build cache.
docker system prune --volumes # ALSO DELETES UNUSED VOLUMES. Data loss. Avoid.
Because every service here keeps its data in a bind-mounted ./data directory rather than a named volume, that last flag can’t reach your data — one more reason for the layout above.
Watchtower, and why not to automate this
Watchtower can update containers automatically. On a homelab, don’t. Unattended updates to packages are good — they’re narrow and heavily tested. Unattended updates to application containers means a breaking schema migration happens while you’re asleep and you find out when your password manager won’t load. Update deliberately, after a backup.
Where this leaves you
Current Docker from the right repository, a predictable filesystem layout, and one service running behind loopback. From here, Chapter 9’s list — Nextcloud, Immich, SearXNG, Jellyfin — is the same pattern repeated, and the next thing to build is a reverse proxy so those services get real hostnames and certificates instead of port numbers.