Automate Docker Container Updates on Your HomeLab with Watchtower and Docker Compose

HomeLab tutorial - IT technology blog
HomeLab tutorial - IT technology blog

The Problem: Manual Updates Are a Hidden Time Sink

It starts innocently enough. You spin up a few Docker containers — Nextcloud, Nginx Proxy Manager, Vaultwarden — and everything runs great. Then three weeks later, a security patch drops for one of them. You SSH in, run docker pull, restart the container, check logs. Fine.

But by month three, you have twelve containers. Some update monthly, some weekly, some nearly daily. Before long, you’re burning an hour every weekend just keeping images current. That’s not a HomeLab anymore — that’s a second job.

I ran into exactly this. My HomeLab had grown to fifteen services, and I dreaded the update routine. I missed a Vaultwarden patch once and it got uncomfortable fast — a colleague asked whether I was running the patched version during a CVE discussion. Not a great moment.

Why This Happens: The Root Cause

Docker images don’t auto-update by default. When you run docker-compose up -d, Docker uses whatever image is already cached locally. Your containers stay frozen in time unless you explicitly run docker-compose pull followed by docker-compose up -d.

The typical manual workflow looks like this:

# Manual update routine (the painful way)
docker pull vaultwarden/server:latest
docker stop vaultwarden
docker rm vaultwarden
docker run -d --name vaultwarden ... vaultwarden/server:latest

Multiply that by twelve containers — each with different flags, volumes, and network settings — and you understand why people avoid it until something actually breaks.

Three Approaches and Why Most People Pick the Wrong One

Option 1: A Simple Cron Job

Some people write a bash script that loops through their services and runs docker-compose pull && docker-compose up -d on a schedule. It works, but the cracks show quickly:

  • No awareness of whether a new image is actually available
  • No notifications — you don’t know what updated or when
  • Brittle: one bad pull can cascade into multiple broken services
  • No rollback mechanism

Option 2: Diun (Docker Image Update Notifier)

Diun monitors your running containers and sends a notification when a newer image is available on the registry. But it stops there — it won’t update anything automatically. Perfect if you want full control, frustrating if you want real automation. For most HomeLab setups, it’s precision tooling applied to the wrong problem.

Option 3: Watchtower (The Right Tool Here)

Watchtower runs as a Docker container itself. It watches your other containers, checks their registries for newer images, pulls the updates, and restarts the containers — all without you touching a keyboard. It works with your existing Docker Compose setup and supports notifications via Slack, Email, Gotify, and Telegram.

For HomeLab use, Watchtower gets the balance right: dead simple to set up, nearly zero maintenance, and flexible enough for real-world edge cases. I’ve run it across fifteen services for over six months. Two container restarts due to upstream breaking changes — both caught immediately by notifications. Zero missed updates.

Setting Up Watchtower with Docker Compose

Step 1: Create the Watchtower Service

Add Watchtower to its own docker-compose.yml file. Keeping it separate from your application stacks makes it easier to manage and update independently.

# /opt/watchtower/docker-compose.yml
services:
  watchtower:
    image: containrrr/watchtower
    container_name: watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_POLL_INTERVAL=86400
      - WATCHTOWER_NOTIFICATIONS=slack
      - WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL=https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK
      - WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER=watchtower-homelab
      - TZ=Asia/Tokyo

Three environment variables worth understanding before you copy-paste:

  • WATCHTOWER_CLEANUP=true — removes old image layers after updating. Skip this and your disk fills up with dangling images. On a busy HomeLab, that’s easily 2–5 GB per month.
  • WATCHTOWER_POLL_INTERVAL=86400 — checks for updates every 24 hours (value is in seconds). Daily is the right cadence for most HomeLabs. Hourly checks are overkill and put unnecessary load on public registries.
  • TZ — set this to your local timezone. Otherwise log timestamps and notification times will be in UTC and you’ll spend thirty seconds doing mental math every time you check a log.

Start it:

cd /opt/watchtower
docker-compose up -d

Step 2: Exclude Containers You Don’t Want Auto-Updated

Not everything should update automatically. Databases are the clearest example — a major version bump on PostgreSQL or MariaDB without a proper migration plan can corrupt your data silently. Don’t let Watchtower touch those.

Label any container you want Watchtower to skip:

# In your app's docker-compose.yml
services:
  postgres:
    image: postgres:15
    container_name: postgres
    labels:
      - "com.centurylinklabs.watchtower.enable=false"
    volumes:
      - postgres_data:/var/lib/postgresql/data

Watchtower reads this label and ignores that container entirely. Apply it to anything where a version bump requires manual intervention: databases, major framework versions, services with complex migration steps.

Step 3: Opt-In Mode for Critical Services

Want tighter control? Flip Watchtower into opt-in mode. Instead of updating everything and excluding exceptions, it only touches containers you’ve explicitly marked safe.

# watchtower docker-compose.yml — opt-in mode
environment:
  - WATCHTOWER_LABEL_ENABLE=true   # Only update labeled containers

Then on each container you want auto-updated:

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

More control, more labels to manage. For a HomeLab with many services, the default exclude-what-you-don’t-want approach is usually less friction. Opt-in makes sense if you’re running something production-adjacent and want explicit sign-off on each auto-updated service.

Step 4: Set Up Notifications

Running silent is a bad habit. If Watchtower updates something and the container fails to restart, you want to know within minutes — not three days later when you notice your RSS reader hasn’t synced since Tuesday.

For Telegram (delivers straight to your phone via a bot):

environment:
  - WATCHTOWER_NOTIFICATION_URL=telegram://BOT_TOKEN@telegram?chats=CHAT_ID

Get your BOT_TOKEN from @BotFather and your CHAT_ID by sending a message to your bot and checking the getUpdates API endpoint. Watchtower uses the shoutrrr URL format for Telegram.

Prefer Gotify (self-hosted push notifications):

environment:
  - WATCHTOWER_NOTIFICATIONS=gotify
  - WATCHTOWER_NOTIFICATION_GOTIFY_URL=http://gotify.yourdomain.com
  - WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN=your_app_token

Or email if you want a paper trail:

environment:
  - WATCHTOWER_NOTIFICATIONS=email
  - [email protected]
  - [email protected]
  - WATCHTOWER_NOTIFICATION_EMAIL_SERVER=smtp.yourdomain.com
  - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=587
  - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=smtp_user
  - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=smtp_password

Step 5: Run a One-Time Update Manually

Sometimes 24 hours is too long. A critical CVE just dropped and you need everything patched now. Watchtower has a run-once mode for exactly this:

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  containrrr/watchtower \
  --run-once \
  --cleanup

This pulls new images, restarts updated containers, cleans up old layers, and exits cleanly. Your running Watchtower instance is untouched.

A Realistic HomeLab Configuration

Here’s what a typical setup looks like with Watchtower integrated — auto-updates on for most services, databases explicitly excluded:

# /opt/apps/docker-compose.yml
services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    # No label needed — Watchtower updates this by default
    volumes:
      - vaultwarden_data:/data

  nextcloud:
    image: nextcloud:latest
    container_name: nextcloud
    restart: unless-stopped
    volumes:
      - nextcloud_data:/var/www/html

  nextcloud_db:
    image: mariadb:10.11
    container_name: nextcloud_db
    restart: unless-stopped
    labels:
      - "com.centurylinklabs.watchtower.enable=false"  # DO NOT auto-update
    environment:
      - MYSQL_ROOT_PASSWORD=yourpassword
      - MYSQL_DATABASE=nextcloud
    volumes:
      - nextcloud_db_data:/var/lib/mysql

volumes:
  vaultwarden_data:
  nextcloud_data:
  nextcloud_db_data:

What to Watch Out For

Watchtower is not magic. A few real gotchas:

  • Images tagged latest only — if you pin to vaultwarden/server:1.30.0, Watchtower has nothing to update to. It works by detecting digest changes on whatever tag you’re tracking.
  • Breaking changes in minor releases — uncommon for well-maintained projects, but it happens. Nextcloud in particular has had minor releases that required database migrations. Check changelogs for services you care about, especially anything with a database backend.
  • Disk space — always set WATCHTOWER_CLEANUP=true. On a 15-container HomeLab updating weekly, uncleaned images can consume 10+ GB within a month.
  • Private registries — pulling from a private registry requires credentials. Watchtower supports ~/.docker/config.json (mount it into the container) or explicit environment variables per registry.

Verify It’s Working

Check Watchtower logs to confirm it’s running and polling correctly:

docker logs watchtower --tail 50 -f

A healthy run looks like this:

time="2026-03-30T09:00:00Z" level=info msg="Checking all containers (except explicitly disabled)"
time="2026-03-30T09:00:12Z" level=info msg="Found new vaultwarden/server:latest image"
time="2026-03-30T09:00:35Z" level=info msg="Stopping vaultwarden (abc123def456)"
time="2026-03-30T09:00:40Z" level=info msg="Creating vaultwarden"
time="2026-03-30T09:00:41Z" level=info msg="Session done" Found=1 Updated=1 Failed=0 Skipped=14 Scanned=15

That Failed=0 is what you’re after. Configure notifications and this summary lands in your Telegram or inbox automatically after every polling cycle — no SSH required.

Share: