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/data is 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_on controls 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-me in a file you might commit to a public git repo. Use a .env file (and .gitignore it) 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/watchtower project 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-stopped
    
    WATCHTOWER_LABEL_ENABLE=true restricts Watchtower to only update containers explicitly opted in with a label:
    services:
      app:
        image: example/app:latest
        labels:
          - "com.centurylinklabs.watchtower.enable=true"
    
    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.

With the basics down, it's time to run services actually worth keeping - see First Services.