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-stoppedso 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>:9443loads 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 downinside it) and runningdocker compose up -dagain 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.