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 -- and understand exactly what access you're granting to make that convenient.

What you'll build and why

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 -- while being explicit about the real access each of those tools requires, which the convenience they offer doesn't make optional to understand.

Don't build this if: you're running exactly one container and have no plans to add more -- a single docker run or one-off Compose file is simpler, and Portainer/Watchtower's overhead (in complexity and in the access they need, see Secure it) isn't worth it for one service.

How it works

  /opt/homelab/
pihole/
docker-compose.yml
etc-pihole/          ◄── this service's own data
jellyfin/
docker-compose.yml
config/               ◄── this service's own data
Portainer  ──  talks to the Docker socket directly
(/var/run/docker.sock) -- sees and can
control every container on the host,
not just the ones it manages a UI for
Watchtower  ──  same socket access -- checks each
running container's image for updates,
pulls and recreates it if one exists

Portainer and Watchtower both need direct access to the Docker socket to do their jobs -- that's not incidental, it's the mechanism that lets either of them see and manage every container on the host, including each other. Before Step 3, it's worth understanding exactly what that means (How it works continues in Secure it).

Before you start

Decision: how much do you trust Portainer and Watchtower with root-equivalent access? Mounting /var/run/docker.sock into a container gives that container control over the Docker daemon -- and the Docker daemon itself runs as root and can do essentially anything on the host through container operations (mount arbitrary host paths, run arbitrary commands as root inside a new container, etc.). This isn't a theoretical concern specific to this guide; it's how Docker socket access works for any tool, and it's why "just mount the socket" is a meaningfully bigger grant of trust than most other volume mounts. Portainer and Watchtower are both widely used, actively maintained, open-source projects -- reasonable to trust for a homelab -- but this is a real trade-off you're making, not a detail to skip past.

Steps

Step 1: Set up a consistent directory structure

Pick one parent directory for everything; give every service its own subdirectory with its own docker-compose.yml and a data folder:

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

Not required by Docker itself, 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

  • Always set restart: unless-stopped so a host reboot brings everything back without manual intervention, without fighting 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:2.45.1-lts
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 promptly -- Portainer requires this within a few minutes of first start or you'll need to restart the container -- and select local to manage this host's Docker environment. Every container across every docker-compose.yml on this host now shows up here, including ones deployed outside Portainer's own UI (this is the direct consequence of the socket access from Before you start).

Step 4: Deploy Watchtower for automatic updates

$ mkdir -p /opt/homelab/watchtower && cd /opt/homelab/watchtower
services:
watchtower:
image: containrrr/watchtower:1.7.1
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

Checks for and applies image updates daily at 4am, then removes the old 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 explicitly call out breaking changes most months) are worth updating deliberately. 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 on this host
  • Stopping a container from a service's own directory (docker compose down) 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
  • Failure test: stop Portainer (docker compose down in its directory). Confirm every other service keeps running unaffected -- Portainer is a management UI, not something other containers depend on to function, and this confirms that's actually true in your setup

Secure it

  • What's exposed: Portainer's web UI (:9443) -- treat access to it as equivalent to root access on the host, because via the Docker socket, it effectively is. Don't expose it beyond your LAN, and don't give an account on it to anyone you wouldn't trust with root.
  • The Docker socket mount, specifically: both Portainer and Watchtower have /var/run/docker.sock mounted -- this is the single biggest security-relevant decision in this whole guide (see Before you start and How it works). It's a reasonable trade-off for the convenience these tools provide, made consciously, not a default to accept without understanding it.
  • Default credentials: Portainer has none by default -- the account you create during the Step 3 setup window is the only one. Watchtower has no UI/credentials at all, it's a background process.
  • Least privilege: Portainer supports creating additional users with scoped permissions (fewer than full admin) -- use that if more than one person needs access, rather than sharing the admin login.
  • Update strategy: Watchtower handles this for most services by design (that's the point of this guide) -- but see the failure that hurts most, below, for why "automatic" isn't automatically safe for everything.
  • The failure that hurts most: Watchtower silently updating something into a broken state overnight, discovered only when you notice a service is down. The step that prevents it: label anything where you'd rather know about an update before it happens (Step 5) -- this is a real, not hypothetical, risk with unattended updates, not just a compliance checkbox.

Back it up and maintain it

What matters: each service's own data directory (Step 1's structure) -- back up /opt/homelab/ as a whole, since it contains every service's config and state in one predictable place. Portainer's own ./data directory holds its UI state (users, stack definitions) -- back it up too, though it's less critical than the services it manages, since Portainer itself is easy to redeploy from this same guide.

Update cadence: Watchtower handles most of this automatically (Step 4) -- your job is reviewing what it's excluded (Step 5) on your own schedule instead.

What to monitor: Watchtower's own logs periodically, to catch a failed update before it becomes a "why is this down" surprise; Portainer's container list for anything unexpectedly stopped.

Troubleshooting

Logs: docker logs portainer, docker logs watchtower, or any individual service's docker logs <container-name>.

Symptom Likely cause Diagnostic Fix
Portainer says the admin account setup window expired More than a few minutes passed between first start and account creation N/A -- this is time-based, not a real error Restart the container (docker compose restart in Portainer's directory) to reset the setup window
Watchtower updated something and it broke The exact risk described in Secure it -- an update introduced a breaking change Check the service's own release notes for what changed Roll back by pulling the specific previous image tag and redeploying; add the exclusion label (Step 5) going forward for that service
A container's data disappeared after recreating it A volume path mistake -- often an anonymous volume instead of a mapped host path Check the Compose file's volume mapping actually points at the persistent directory from Step 1 Fix the mapping; already-lost data in an anonymous volume that got removed isn't recoverable without a backup
Portainer shows a container as "unhealthy" that seems to be working fine The container has a defined healthcheck that's failing for a reason unrelated to actual functionality (e.g. checking the wrong port internally) docker inspect <container> and look at the Healthcheck section Usually a healthcheck config issue in that service's own image, not a Portainer problem -- check the image's own documentation for the expected healthcheck behavior
Watchtower's logs show it skipping a container you expected it to update The com.centurylinklabs.watchtower.enable=false label is present (deliberately, from Step 5, or inherited from a copied Compose file you didn't intend to exclude) Check that service's Compose file for the label Remove the label if exclusion wasn't intentional
Can't reach Portainer's UI at all after deploying Port 9443 not actually exposed, or a firewall blocking it docker ps to confirm the port mapping; check host firewall rules Fix the port mapping in the compose file, or open the port on the host firewall if one is active

Undo

Stop and remove Portainer or Watchtower independently (docker compose down in their respective directories) -- neither is required for the other services to keep running (confirmed by the failure test above). To fully undo this pattern, migrate each service back to however you managed it before, or just leave the directory structure in place -- it's a plain filesystem layout, not something that needs "uninstalling."

Go further

  • Self-Hosted Media Server with Jellyfin and Home Assistant for Home Automation -- both fit directly into the structure this guide sets up
  • Check your /opt/homelab/ tree into a private git repository (excluding secrets) so your whole homelab's configuration is versioned
  • Diun is a lighter-weight alternative to Watchtower if you'd rather be notified of available updates than have them applied automatically -- worth considering given the Secure it discussion above, if you decide automatic updates aren't the right trade-off for your setup

Resources

Official documentation:

Source and releases:

Community:

Go deeper:

  • Diun -- the notify-rather-than-auto-update alternative mentioned in Go further

Related DaemonPress projects:


Last verified: 2026-09-21, checked against official Docker, Portainer, and Watchtower documentation (see Resources above).

Changelog:

  • 2026-09-21 -- Full rewrite: pinned Portainer and Watchtower to their confirmed current releases (2.45.1 LTS, 1.7.1) instead of latest. Added the single biggest gap the audit flagged: an explicit, specific explanation of what mounting the Docker socket actually grants (root-equivalent host control, not a generic "needs access to manage containers" hand-wave) -- in How it works, Before you start, and Secure it, not just a passing mention. Added a directory-structure/socket-access diagram, a "don't build this if," a real failure test, Undo, and a full Resources section. Expanded Troubleshooting from 3 to 6. Moved from infrastructure-devops to virtualization-containers.
  • 2026-09-21 (earlier) -- Original version published without discussing the Docker socket's security implications.