Docker Compose Basics
Your first compose file, running multiple services together, and why compose (not individual docker run commands) is how you should actually manage a homelab.
Compose describes a service (or several) in a YAML file instead of a
long docker run command line. It's the practical unit of a homelab -
almost every self-hosted project's documentation gives you a compose
example, and it's how you'll manage services from here on.
Why compose over individual docker run commands
A docker run command has to be remembered or scripted to be
reproducible. A compose file is that record, checked into a folder (or
git repo) alongside the service's data, and it can start, stop, and
recreate the exact same setup with one command. When something breaks,
you're debugging a file you can read top to bottom, not
reverse-engineering a shell history.
Your first compose file
Create a folder per service - keeps things organized as the list grows:
mkdir -p ~/homelab/whoami
cd ~/homelab/whoami
docker-compose.yml:
services:
whoami:
image: traefik/whoami
container_name: whoami
ports:
- "8080:80"
restart: unless-stopped
docker compose up -d
That's the same container as in Docker Essentials, just declared instead of typed as a one-off command. Tear it down with:
docker compose down
Multiple services and volumes together
Most real services need persistent storage and sometimes a second container (a database, for instance). Here's a slightly more realistic example:
services:
app:
image: example/app:latest
container_name: app
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
- TZ=Europe/London
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16
container_name: app-db
volumes:
- ./db-data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=change-me
restart: unless-stopped
Notes worth internalizing:
./data:/app/datais a bind mount - it maps a folder on the host directly into the container, so you can see and back up the files directly from the host filesystem. This is usually the right choice for a homelab, since it makes backups (see Backups 101) straightforward.depends_oncontrols start order, not readiness - the app container will start before Postgres is necessarily ready to accept connections. Most images handle this with an internal retry; check a given image's docs if you see connection errors on first boot.- Never leave a default password like
change-mein a file you might commit to a public git repo. Use a.envfile (and.gitignoreit) for anything secret.
.env files for secrets and repeated values
# .env
TZ=Europe/London
DB_PASSWORD=a-real-generated-password
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
Compose automatically reads a .env file in the same directory. Add
.env to .gitignore if this folder is under version control.
Everyday compose commands
docker compose up -d # start (or recreate changed) services
docker compose down # stop and remove containers (volumes persist)
docker compose logs -f app # follow one service's logs
docker compose pull # pull newer images
docker compose up -d --pull always # pull and recreate in one step
Keeping images updated
docker compose pull && docker compose up -d is the whole update
process for a compose-managed service - pull whatever's new for the
tag you're tracking, recreate the container if the image changed. The
only real decision is whether you run that by hand or automate it:
- By hand, periodically - full control over what changes and when, and you notice a breaking change immediately because you were watching. The right default for anything security-sensitive, like Vaultwarden, where an unattended update landing badly at 3am is worse than being a few weeks behind on patches.
- Watchtower - a
container that watches for new image versions and updates matching
containers automatically, no cron job required. The original
containrrr/watchtowerproject was archived by its maintainers in late 2025; use the actively maintained community fork's image (nickfedor/watchtower) instead - it's a drop-in replacement with the same configuration:services: watchtower: image: nickfedor/watchtower:latest container_name: watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_POLL_INTERVAL=86400 - WATCHTOWER_LABEL_ENABLE=true restart: unless-stoppedWATCHTOWER_LABEL_ENABLE=truerestricts Watchtower to only update containers explicitly opted in with a label:
This lets you automate the low-stakes services (a dashboard, a media server) while leaving anything security-critical on manual updates - a reasonable middle ground rather than an all-or-nothing choice.services: app: image: example/app:latest labels: - "com.centurylinklabs.watchtower.enable=true"
With the basics down, it's time to run services actually worth keeping - see First Services.