Watchtower is a container that watches your other containers: it periodically checks the registry for a newer image behind each container's tag and, when it finds one, pulls it and recreates the container with the same options. It is useful for keeping small self-hosted services current without logging in to run docker pull by hand. In this tutorial you will run Watchtower with Docker Compose on Ubuntu 24.04, limit it to the containers you opt in, schedule updates for a quiet hour, clean up old images and receive a notification after each update.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository.
  • A non-root user with sudo privileges who can run docker commands.
  • Optionally, a Discord, Slack or Telegram webhook, or an SMTP account, for notifications.

When automatic updates make sense

Watchtower updates whatever image the tag points to at that moment. That is convenient, but it also means an upstream release can restart or break a service without warning. Use it with these rules:

  • Good fits: stateless services and tools with stable tags, such as reverse proxies, dashboards, small web apps, or images you build and push yourself.
  • Monitor only: databases and anything with schema migrations. Get notified and update them by hand after reading the release notes.
  • Pin tags: use tags like nginx:1.27-alpine or postgres:16 rather than latest, so updates stay within a major version.
  • Have backups: an update that runs a migration cannot be undone by going back to the old image.

Step 1 - Creating a test service

Create a project directory with a simple Nginx container that Watchtower will manage:

mkdir -p ~/apps/web && cd ~/apps/web
nano compose.yaml
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    restart: unless-stopped
    labels:
      com.centurylinklabs.watchtower.enable: "true"

The label marks this container as opted in. In Step 2 you will tell Watchtower to ignore every container without it. Start the service:

docker compose up -d
docker compose ps
NAME        IMAGE          COMMAND                  SERVICE   CREATED         STATUS         PORTS
web-web-1   nginx:alpine   "/docker-entrypoint.…"   web       5 seconds ago   Up 4 seconds   0.0.0.0:8080->80/tcp

Step 2 - Deploying Watchtower with Docker Compose

Create a separate directory for Watchtower so it does not depend on any application project:

mkdir -p ~/apps/watchtower && cd ~/apps/watchtower
nano compose.yaml
services:
  watchtower:
    image: nickfedor/watchtower:latest
    container_name: watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      TZ: Europe/Madrid
      WATCHTOWER_SCHEDULE: "0 0 4 * * *"
      WATCHTOWER_LABEL_ENABLE: "true"
      WATCHTOWER_CLEANUP: "true"

What each setting does:

  • /var/run/docker.sock: Watchtower needs the Docker API to inspect, pull and recreate containers.
  • TZ: the time zone used to interpret the schedule. Set it to your own, for example UTC or America/New_York.
  • WATCHTOWER_SCHEDULE: a cron expression with six fields, the first one being seconds. 0 0 4 * * * means every day at 04:00. Without a schedule, Watchtower checks every 24 hours from the moment it starts.
  • WATCHTOWER_LABEL_ENABLE: only containers with the com.centurylinklabs.watchtower.enable=true label are updated. Everything else is left alone.
  • WATCHTOWER_CLEANUP: removes the old image after a successful update, so outdated images do not fill the disk.

Start Watchtower and check its log:

docker compose up -d
docker logs watchtower
level=info msg="Watchtower ..."
level=info msg="Using no notifications"
level=info msg="Only checking containers using enable label"
level=info msg="Scheduling first run: 2026-09-26 04:00:00 +0200 CEST"

The log confirms the label filter and the time of the next run.

Step 3 - Running an update check on demand

Waiting until 04:00 is not a good way to test the setup. Run a one-off Watchtower container that checks immediately and exits. Passing a container name limits the run to that container:

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  nickfedor/watchtower --run-once web-web-1
level=info msg="Watchtower ..."
level=info msg="Running a one time update."
level=info msg="Session done" Failed=0 Scanned=1 Updated=0 notify=no

Scanned=1 Updated=0 means Watchtower checked the container and the image is already current. When a newer image exists, the log shows Found new nginx:alpine image, the container being stopped and recreated, and Updated=1.

Step 4 - Excluding containers and using monitor-only mode

With WATCHTOWER_LABEL_ENABLE set, containers are excluded simply by not having the label. For services you want to watch but not update automatically, such as databases, add the monitor-only label instead:

services:
  db:
    image: postgres:16
    labels:
      com.centurylinklabs.watchtower.enable: "true"
      com.centurylinklabs.watchtower.monitor-only: "true"

Watchtower checks this container on schedule and reports new images through notifications, but never restarts it. Recreate the service with docker compose up -d after changing its labels, because labels are only read from the container, not from the file.

If you prefer the opposite policy (update everything except a few containers), remove WATCHTOWER_LABEL_ENABLE from the Watchtower service and set com.centurylinklabs.watchtower.enable: "false" on the containers to skip.

Step 5 - Configuring notifications

Watchtower sends notifications through Shoutrrr URLs, a single setting that supports Discord, Slack, Telegram, email and many other services. For a Discord webhook such as https://discord.com/api/webhooks/123456789/AbCdEf, the URL is discord://AbCdEf@123456789 (token first, then the webhook ID).

Store the URL in an .env file next to the Compose file so it stays out of the YAML:

cd ~/apps/watchtower
nano .env
WATCHTOWER_NOTIFICATION_URL=discord://your_webhook_token@your_webhook_id

Restrict the file's permissions, since the URL is a secret:

chmod 600 .env

Add these lines to the environment block of the Watchtower service in compose.yaml:

      WATCHTOWER_NOTIFICATION_URL: ${WATCHTOWER_NOTIFICATION_URL}
      WATCHTOWER_NOTIFICATIONS_HOSTNAME: your_server_name

WATCHTOWER_NOTIFICATIONS_HOSTNAME sets the server name shown in the messages, which helps when several servers report to the same channel. Other common URL formats are:

ServiceShoutrrr URL
Slackslack://token-a/token-b/token-c (from the webhook URL)
Telegramtelegram://bot_token@telegram?chats=chat_id
Emailsmtp://user:[email protected]:587/[email protected]&[email protected]

Recreate the container and check the log:

docker compose up -d
docker logs watchtower 2>&1 | grep -i notif
level=info msg="Using notifications: discord"

Watchtower sends a startup message to the channel. From now on it posts a summary whenever a run finds or applies updates.

Step 6 - Updating images from private registries

To update containers whose images come from a private registry, Watchtower needs the same credentials as the Docker CLI. Log in on the host, then mount the credentials file read-only into the container:

docker login registry.example.com

Add the file to the volumes list of the Watchtower service:

    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /home/your_user/.docker/config.json:/config.json:ro

Run docker compose up -d again to apply it. This only works when config.json stores the credentials directly; if docker login used a credential helper, the file contains no password for Watchtower to read.

Step 7 - Checking what Watchtower did

Watchtower logs every run. After the scheduled time, review the last session:

docker logs --since 24h watchtower
level=info msg="Found new nginx:alpine image (3b25b682ea82)"
level=info msg="Stopping /web-web-1 (d1f6a2c9e0b3) with SIGTERM"
level=info msg="Creating /web-web-1"
level=info msg="Removing image 1f5a3c8e9d20"
level=info msg="Session done" Failed=0 Scanned=1 Updated=1 notify=no

Confirm the service still responds after an update:

curl -I http://localhost:8080
HTTP/1.1 200 OK

Troubleshooting

  • client version 1.25 is too old: you are running the unmaintained containrrr/watchtower image against a recent Docker Engine. Switch the image to nickfedor/watchtower.
  • A container is never updated: check that it has the enable label (docker inspect -f '{{json .Config.Labels}}' web-web-1) and that its image comes from a registry. Locally built images with no registry cannot be checked.
  • Updates run at the wrong hour: the schedule has six fields starting with seconds, and uses the TZ variable. 0 4 * * * is not a valid Watchtower schedule.
  • Registry rate limits: Docker Hub limits anonymous pulls. Log in as in Step 6 and avoid very frequent schedules.

Conclusion

You deployed Watchtower with Docker Compose, restricted it to containers that opt in by label, scheduled updates for a fixed hour, enabled cleanup of old images and set up notifications, with monitor-only mode for services that need manual upgrades. As next steps, pin release tags for your critical services, pair Watchtower with a daily volume backup, and add a health check to each service so broken updates are noticed quickly.