Tutorials

Docker and Compose, Properly Installed

The right repository, the docker group and what it actually grants, a Compose file you can read, and the update routine that keeps a homelab from rotting.

intermediate ⏱ 45 min Groundwork for Chapter 9 dockercomposehomelab

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:

SourceWhat you get
apt install docker.ioUbuntu’s own fork. Frequently a major version behind; no Compose v2.
snap install dockerConfined. Bind-mounting host paths fails in ways that waste an afternoon.
Docker’s official apt repoCurrent 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.