Managing a Homelab with Docker Compose, Portainer, and Watchtower

Go from a pile of one-off `docker run` commands to a consistent, visible, auto-updating set of self-hosted services.

A docker run command with a dozen flags works fine the first time. The second time you need to recreate that container -- after a host reboot, a disk failure, or moving to new hardware -- remembering every flag exactly is how homelabs quietly rot. A Compose file is that same configuration written down once, versioned, and reproducible with one command. This guide sets up a consistent directory structure for multiple services, a one-pane-of-glass UI with Portainer, and automatic updates with Watchtower.

Step 1: Set up a consistent directory structure

Pick one parent directory for everything and give every service its own subdirectory with its own docker-compose.yml and a data/ (or similarly named) folder for anything that needs to persist:

/opt/homelab/
pihole/
docker-compose.yml
etc-pihole/
jellyfin/
docker-compose.yml
config/
homeassistant/
docker-compose.yml
config/

This isn't strictly required by Docker, but it's the difference between "I know exactly where every service's config and data live" and guesswork six months from now.

Step 2: Standardize on a few Compose conventions

Two small habits pay off across every service you add:

  • Always set restart: unless-stopped so a host reboot brings everything back without manual intervention (but doesn't fight you if you deliberately stopped something).
  • Pin volumes to relative paths inside each service's own directory (./config:/config, not an absolute path elsewhere), so the whole /opt/homelab/ tree is self-contained and portable to a new host by copying it.

Step 3: Deploy Portainer for a unified view

$ mkdir -p /opt/homelab/portainer && cd /opt/homelab/portainer
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
ports:
- "9443:9443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "./data:/data"
restart: unless-stopped
$ docker compose up -d

Visit https://<host-ip>:9443, create the admin account (do this promptly -- Portainer requires it within a few minutes of first start or you'll need to restart the container), and select local to manage the Docker environment on this same host. Every container across every docker-compose.yml you run on this host -- including ones you deploy outside Portainer's own UI -- now shows up here.

Step 4: Deploy Watchtower for automatic updates

$ mkdir -p /opt/homelab/watchtower && cd /opt/homelab/watchtower
services:
watchtower:
image: containrrr/watchtower
container_name: watchtower
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
environment:
WATCHTOWER_SCHEDULE: "0 0 4 * * *"
WATCHTOWER_CLEANUP: "true"
restart: unless-stopped
$ docker compose up -d

This checks for and applies image updates daily at 4am, then removes the old, now-unused image afterward (WATCHTOWER_CLEANUP). Watchtower updates every running container by default -- Step 5 covers opting specific ones out.

Step 5: Exclude anything you don't want auto-updated

Some services (anything where a breaking update matters more than staying current -- Home Assistant is a common example, since its release notes regularly call out breaking changes) are worth updating deliberately instead of automatically. Add this label to that service's Compose file to exclude it from Watchtower:

    labels:
- "com.centurylinklabs.watchtower.enable=false"

Verify it works

  • https://<host-ip>:9443 loads Portainer and lists every running container from every service you've deployed on this host
  • Stopping a container from a service's own directory (docker compose down inside it) and running docker compose up -d again brings it back with the same configuration and data intact
  • Watchtower's logs (docker logs watchtower) show it checking for updates on its configured schedule
  • A service labeled to exclude Watchtower is confirmed absent from its update logs, while others are checked normally

Troubleshooting

Portainer says the admin account setup window expired

Restart the container (docker compose restart in Portainer's directory) -- this resets the setup window without losing any other data.

Watchtower updated something and it broke

This is exactly the risk of automatic updates, and why Step 5 exists. Roll back by pulling the specific previous image tag (check the project's release notes/tags for the last known-good version) and redeploying, then add the exclusion label going forward for that service.

A container's data disappeared after recreating it

Almost always a volume path mistake -- confirm the Compose file's volume mapping actually points at the persistent directory (Step 1/2) and wasn't left as an anonymous volume or a path that got typo'd differently between runs.

Next steps

Once you're comfortable with this structure, look into checking your /opt/homelab/ tree into a private git repository (excluding secrets) so your whole homelab's configuration is versioned, and consider Diun as a lighter-weight alternative to Watchtower if you'd rather be notified of available updates than have them applied automatically.

Sources

Based on the official Docker Compose documentation, Portainer documentation, and Watchtower documentation.