Wc
Observability Azure

WUD Container Update Monitor on Ubuntu 24.04 on Azure User Guide

| Product: WUD Container Update Monitor on Ubuntu 24.04 LTS on Azure

Overview

WUD ("What's up Docker?") is an open source container update monitor. It reads the container runtime on a host, works out which image and tag each running container came from, asks that image's registry whether a newer version exists, and reports the answer on a web dashboard and a REST API. When an update appears it fires whichever notification integrations you have configured, so the news reaches you instead of waiting to be noticed.

The cloudimg image serves WUD 8.3.1 on a hardened, fully patched Ubuntu 24.04 LTS base. WUD is installed natively from its pinned upstream release on Node.js 24 LTS, listens only on the loopback interface, and is published by nginx on port 80. A unique administrator credential is generated on the first boot of every VM, and the monitor refuses to start at all if that credential is missing, so an unauthenticated dashboard can never ship. Backed by 24/7 cloudimg support.

The image is also self proving. It boots with a real workload already running on a deliberately out of date tag, served by a local registry that carries newer tags, so the dashboard shows a genuine detected update the first time you sign in rather than an empty screen. That demonstration needs no registry credentials and no outbound network, and you can remove it in one step once you have pointed WUD at your own containers.

Trademark notice. "Docker" is a trademark of Docker, Inc. It is used in this guide only to describe the container runtime and image registries that WUD monitors. cloudimg is not affiliated with, endorsed by, or sponsored by Docker, Inc.

What is included:

  • WUD 8.3.1 installed natively on Node.js 24 LTS, managed by systemd
  • The web dashboard and the full REST API on :80, behind HTTP Basic authentication from the first request
  • A per VM administrator credential generated on first boot and recorded in a root only file
  • The monitor bound to 127.0.0.1:3000 only, never exposed directly to the network
  • A container runtime whose API is never published over TCP, with the monitor's service account holding only the minimum group membership needed to read the local socket
  • A packet policy that keeps the monitor, the demonstration webhook sink and the local registry off box
  • A bundled demonstration workload and loopback registry so update detection is provable on first boot
  • An open /health endpoint for Azure Load Balancer health probes
  • 24/7 cloudimg support

WUD dashboard showing a detected update: the watched container cloudimg-demo-app is running tag 1.0.0 and WUD reports 1.2.0 is available

Prerequisites

An active Azure subscription, an SSH key pair, and a VNet plus subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a sensible starting point; size up if you will watch many hosts or run a short watch interval. NSG inbound: allow 22/tcp from your management network and 80/tcp for the dashboard, restricted to the addresses that need it. WUD serves plain HTTP on port 80; for production, terminate TLS in front of it with your own domain and certificate.

Step 1 - Deploy from the Azure Marketplace

In the Azure portal choose Create a resource, search for WUD Container Update Monitor on Ubuntu 24.04 and select the cloudimg offering. Pick your subscription, resource group and region, choose Standard_B2s, select SSH public key authentication with the username azureuser, and allow inbound ports 22 and 80. Review and create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group my-resource-group \
  --name wud-01 \
  --image cloudimg:wud-ubuntu-24-04:default:latest \
  --size Standard_B2s \
  --storage-sku StandardSSD_LRS \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

# Then open the port the dashboard needs
az vm open-port --resource-group my-resource-group --name wud-01 --port 80 --priority 1001

Step 3 - Confirm the services are running

SSH in as azureuser and check the units. Six units make up the appliance: the monitor itself, the nginx reverse proxy, the container runtime, the bundled demonstration registry, the demonstration webhook sink, and the packet policy.

sudo systemctl is-active wud nginx docker cloudimg-registry cloudimg-wud-sink cloudimg-wud-nft
active
active
active
active
active
active

Only port 80 (and SSH) is reachable from off the VM. The monitor's own listener, the demonstration sink and the local registry are all confined to the loopback interface or dropped by the packet policy:

sudo ss -ltn | grep -E ':80 |:3000|:5000|:3001'
LISTEN 0 4096 127.0.0.1:3001 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:5000 0.0.0.0:*
LISTEN 0 511  0.0.0.0:80     0.0.0.0:*
LISTEN 0 511  *:3000         *:*

Port 3000 binds every interface because the upstream application offers no bind address option, so the appliance drops it at the packet filter instead. You can see the policy, and confirm that the container API is not published over TCP:

sudo nft list table inet cloudimg_wud
sudo ss -ltn | grep -E ':2375|:2376' || echo 'container API is not published over TCP'
table inet cloudimg_wud {
    chain input {
        type filter hook input priority filter; policy accept;
        iifname "lo" accept
        tcp dport { 3000, 3001, 5000 } drop
    }
}
container API is not published over TCP

Terminal showing the six appliance services active, only port 80 publicly bound, and the versions of the monitor's runtime and container engine

The health endpoint is deliberately open, so an Azure Load Balancer probe can use it without a credential. It reports uptime only and exposes no container data:

curl -s http://localhost/health
{"uptime":179.85231959}

Step 4 - Retrieve the per VM administrator credential

The credential is generated on the first boot of this VM and written to a root only file. There is no default password anywhere in the image.

sudo cat /root/wud-credentials.txt
# WUD Container Update Monitor - generated on first boot by wud-firstboot.service.
# These credentials are unique to this VM. Store them somewhere safe.

WUD_URL=http://20.10.20.30/
wud.admin.user=wudadmin
wud.admin.pass=<WUD_ADMIN_PASSWORD>

Everything except /health is refused without it, and a guessed password gets you nowhere:

curl -s -o /dev/null -w 'anonymous:      %{http_code}\n' http://localhost/api/containers
curl -s -o /dev/null -w 'guessed:        %{http_code}\n' -u admin:admin http://localhost/api/containers
WUD_USER=$(sudo grep '^wud.admin.user=' /root/wud-credentials.txt | cut -d= -f2-)
WUD_PASS=$(sudo grep '^wud.admin.pass=' /root/wud-credentials.txt | cut -d= -f2-)
curl -s -o /dev/null -w 'per VM login:   %{http_code}\n' -u "$WUD_USER:$WUD_PASS" http://localhost/api/containers
anonymous:      401
guessed:        401
per VM login:   200

Terminal showing anonymous and guessed logins refused with 401 while the per VM credential returns 200, and the root only permissions on the credential files

Step 5 - Sign in to the dashboard

Browse to http://<vm-public-ip>/. Your browser will ask for the username and password from Step 4. Until you supply them the dashboard is refused outright:

Browser showing the appliance refusing access without a credential, an HTTP 401 authorization required response

Once signed in the home view summarises what the monitor is tracking. On a freshly deployed VM it already reports one container being watched and one update available:

WUD home view summarising one container watched with one update available, one trigger, one watcher and eight registries

Step 6 - Read the update WUD has already found

Open Containers. The bundled demonstration workload runs the tag 1.0.0, and WUD has queried the registry and found 1.2.0. Expanding the row shows the update detail: the newer tag, how big a change it is in semantic versioning terms, and the release link the container's label points at.

WUD container detail panel showing the available tag 1.2.0, a minor update kind, and the release link configured on the container

The same answer is available from the API. POST /api/containers/watch forces an immediate check rather than waiting for the schedule:

WUD_USER=$(sudo grep '^wud.admin.user=' /root/wud-credentials.txt | cut -d= -f2-)
WUD_PASS=$(sudo grep '^wud.admin.pass=' /root/wud-credentials.txt | cut -d= -f2-)
curl -s -X POST -u "$WUD_USER:$WUD_PASS" http://localhost/api/containers/watch > /dev/null
curl -s -u "$WUD_USER:$WUD_PASS" http://localhost/api/containers \
  | jq '.[] | {name, watcher, updateAvailable, running: .image.tag.value, available: .result.tag, kind: .updateKind.semverDiff}'
{
  "name": "cloudimg-demo-app",
  "watcher": "local",
  "updateAvailable": true,
  "running": "1.0.0",
  "available": "1.2.0",
  "kind": "minor"
}

Terminal showing the running demonstration container, a forced watch, and the API reporting updateAvailable true with 1.0.0 running and 1.2.0 available

Step 7 - Watch your own containers

Any container you run on this host is watched automatically. Labels on a container tell WUD which tags count as an upgrade, so a container pinned to a stable line is never told about a pre release: wud.tag.include is a regular expression matched against the registry's tags, and wud.link.template turns the result into a release link in the UI.

Start a second container with those labels and force a watch. This example uses the image already on the VM so it works with no outbound network:

sudo docker run -d --name my-labelled-app --restart unless-stopped \
  --label 'wud.tag.include=^\d+\.\d+\.\d+$' \
  --label 'wud.link.template=https://www.cloudimg.co.uk/guides/wud-on-ubuntu-24-04-azure/' \
  127.0.0.1:5000/cloudimg/demo-app:1.1.0
WUD_USER=$(sudo grep '^wud.admin.user=' /root/wud-credentials.txt | cut -d= -f2-)
WUD_PASS=$(sudo grep '^wud.admin.pass=' /root/wud-credentials.txt | cut -d= -f2-)
curl -s -X POST -u "$WUD_USER:$WUD_PASS" http://localhost/api/containers/watch > /dev/null
curl -s -u "$WUD_USER:$WUD_PASS" http://localhost/api/containers \
  | jq -r '.[] | "\(.name)  running \(.image.tag.value)  available \(.result.tag)"'
cloudimg-demo-app  running 1.0.0  available 1.2.0
my-labelled-app    running 1.1.0  available 1.2.0

Your own containers work exactly the same way, whatever registry they came from:

docker run -d --name my-app --restart unless-stopped \
  --label 'wud.tag.include=^\d+\.\d+\.\d+-alpine$' \
  --label 'wud.link.template=https://github.com/nginx/nginx/releases/tag/release-${major}.${minor}.${patch}' \
  nginx:1.28.0-alpine

The watcher's schedule is set in /etc/wud/wud.env as WUD_WATCHER_LOCAL_CRON, which ships as every fifteen minutes. Widen it if you watch many images from a rate limited public registry:

sudo grep -E 'WUD_WATCHER_LOCAL_(CRON|SOCKET)' /etc/wud/wud.env
WUD_WATCHER_LOCAL_SOCKET=/var/run/docker.sock
WUD_WATCHER_LOCAL_CRON=*/15 * * * *

Step 8 - Registries

Eight registries are configured out of the box, including anonymous access to the public hub, so images you pull from public sources are watched with no credentials at all.

WUD registries view listing the eight configured registries, including the bundled loopback demonstration registry and anonymous public hub access

WUD_USER=$(sudo grep '^wud.admin.user=' /root/wud-credentials.txt | cut -d= -f2-)
WUD_PASS=$(sudo grep '^wud.admin.pass=' /root/wud-credentials.txt | cut -d= -f2-)
curl -s -u "$WUD_USER:$WUD_PASS" http://localhost/api/registries | jq -r '.[].id' | paste -sd' ' -
codeberg.public custom.cloudimgdemo ecr.public forgejo.public gcr.public ghcr.public hub.public quay.public

To watch images held in a private registry, add its credentials to /etc/wud/wud.env and restart the monitor. Keep any file that holds a secret at mode 0600 and owned by root, exactly as the shipped credential files are.

WUD_REGISTRY_GHCR_PRIVATE_USERNAME=my-github-user
WUD_REGISTRY_GHCR_PRIVATE_TOKEN=my-personal-access-token
sudo systemctl restart wud

Step 9 - Send the news somewhere

A trigger is what turns a detected update into a notification. The image ships one worked example: an HTTP webhook pointed at a local sink, so you can see a real delivery end to end before wiring up your own endpoint.

WUD triggers view showing the configured http trigger and its delivery settings

Every delivery is recorded, so you can confirm a trigger genuinely fired rather than merely being configured:

sudo tail -1 /var/log/cloudimg-wud-trigger.log | cut -d' ' -f4- \
  | jq -c '{name, updateAvailable, running: .image.tag.value, available: .result.tag}'
{"name":"cloudimg-demo-app","updateAvailable":true,"running":"1.0.0","available":"1.2.0"}

Terminal showing a recorded trigger delivery, the container API absent from TCP, the socket ownership and the packet policy

To point the webhook at your own endpoint, or to use email or chat instead, edit /etc/wud/wud.env and restart the monitor. WUD supports email, Slack, Discord, Telegram, ntfy, Gotify, Pushover, Rocket.Chat, Apprise, MQTT, Kafka, IFTTT and generic HTTP.

WUD_TRIGGER_HTTP_CLOUDIMGSINK_URL=https://my-endpoint.example.com/wud

WUD_TRIGGER_SMTP_TEAM_HOST=smtp.example.com
WUD_TRIGGER_SMTP_TEAM_PORT=587
WUD_TRIGGER_SMTP_TEAM_FROM=wud@example.com
WUD_TRIGGER_SMTP_TEAM_TO=platform-team@example.com

Step 10 - Remove the demonstration harness

The demonstration workload, the loopback registry and the webhook sink exist only to prove the appliance works on first boot. Once you are watching your own containers, remove them. Nothing else depends on them.

sudo docker rm -f cloudimg-demo-app
sudo systemctl disable --now cloudimg-registry cloudimg-wud-sink
sudo sed -i '/CLOUDIMGDEMO\|CLOUDIMGSINK/d' /etc/wud/wud.env
sudo systemctl restart wud

Step 11 - Verify the stack

This block is the appliance's own end to end check. It confirms the services are up, the health probe answers without a credential, anonymous access is refused, the per VM credential works, and no credential has leaked outside the two root only files it belongs in.

set -e
WUD_USER=$(sudo grep '^wud.admin.user=' /root/wud-credentials.txt | cut -d= -f2-)
WUD_PASS=$(sudo grep '^wud.admin.pass=' /root/wud-credentials.txt | cut -d= -f2-)

for unit in wud nginx docker; do
  test "$(systemctl is-active $unit)" = active && echo "unit $unit: active"
done

test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost/health)" = 200 \
  && echo 'health endpoint: open, 200'
test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost/api/containers)" = 401 \
  && echo 'anonymous API: refused, 401'
test "$(curl -s -o /dev/null -w '%{http_code}' -u "$WUD_USER:$WUD_PASS" http://localhost/api/containers)" = 200 \
  && echo 'per VM credential: accepted, 200'

test "$(sudo stat -c '%a %U' /root/wud-credentials.txt)" = '600 root' \
  && echo 'credentials file: 0600 root'
test "$(sudo stat -c '%a %U' /etc/wud/wud-auth.env)" = '600 root' \
  && echo 'auth env: 0600 root'

# NOTE: the password is read INSIDE the root shell, never passed as an argument to
# sudo. sudo logs the command line it runs to /var/log/auth.log, so `sudo grep -- "$PASS"`
# would write this VM's administrator password into a log file and defeat its own check.
LEAKS=$(sudo bash -c 'P=$(grep "^wud.admin.pass=" /root/wud-credentials.txt | cut -d= -f2-);
        grep -rIl -- "$P" /etc /var/log /var/backups /home /tmp 2>/dev/null \
        | grep -v "^/etc/wud/wud-auth.env$"' || true)
test -z "$LEAKS" && echo 'credential residue: none outside the root only files'
unit wud: active
unit nginx: active
unit docker: active
health endpoint: open, 200
anonymous API: refused, 401
per VM credential: accepted, 200
credentials file: 0600 root
auth env: 0600 root
credential residue: none outside the root only files

Step 12 - Back up your configuration

Back up the monitor's configuration and its store. Do not include the credential files: /etc/wud/wud-auth.env and /root/wud-credentials.txt hold this VM's administrator secret, and a backup that contains them turns one protected file into an unprotected copy. The archive below excludes them by construction, and the check afterwards proves it.

sudo tar -czf /var/backups/wud-config-$(date +%F).tar.gz \
  --exclude='wud-auth.env' \
  -C / etc/wud var/lib/wud
sudo chmod 0600 /var/backups/wud-config-$(date +%F).tar.gz
sudo tar -tzf /var/backups/wud-config-$(date +%F).tar.gz | grep -q 'wud-auth.env' \
  && echo 'LEAK: the auth env is in the archive' || echo 'auth env excluded: yes'
# Again, the password is read inside the root shell so it never reaches a sudo command line.
sudo bash -c 'P=$(grep "^wud.admin.pass=" /root/wud-credentials.txt | cut -d= -f2-);
  zgrep -q -- "$P" /var/backups/wud-config-'"$(date +%F)"'.tar.gz' \
  && echo 'LEAK: the credential is in the archive' || echo 'no credential in the archive'
auth env excluded: yes
no credential in the archive

To restore, stop the monitor, unpack the archive over /, and start it again. The per VM administrator credential is not restored from a backup: it belongs to the VM, and /etc/wud/wud-auth.env on the target VM keeps whichever credential that VM generated on its own first boot.

Security notes

  • There is no default login. The administrator credential is generated on the first boot of each VM and written to /root/wud-credentials.txt at mode 0600. The monitor's systemd unit will not start unless a real credential is present, so an unauthenticated dashboard cannot ship. Change the password by replacing the hash in /etc/wud/wud-auth.env (openssl passwd -apr1) and the matching line in /etc/nginx/cloudimg-wud.htpasswd, then restart wud and nginx.
  • Anyone who can sign in can see this host's container inventory - container names, image names, tags and digests. Treat the credential as privileged and restrict inbound 80/tcp in your Network Security Group to the addresses that need it.
  • The container socket is the monitor's only elevated privilege. WUD runs as the dedicated wud system account, which has no login shell and belongs to exactly two groups: its own, and the group that owns the container socket. The socket stays 0660 root:docker on the host and the container API is never published over TCP. Access to that socket is equivalent to root on the host, which is why the monitor is confined to loopback and reached only through the authenticated proxy.
  • Only port 80 is reachable. The monitor (3000), the demonstration sink (3001) and the loopback registry (5000) are dropped on every interface except loopback by the cloudimg-wud-nft packet policy. That policy adds and removes only its own table, so it never disturbs the rules the container runtime installs for container networking.
  • Terminate TLS in front of the appliance for anything beyond a private network. HTTP Basic sends the credential on every request, and the upstream application derives its session signing key deterministically from the host's MAC address and version rather than from a random secret; Basic authentication is the real gate on every request, but TLS is what keeps the credential off the wire.
  • Security updates keep flowing. The container runtime and the Node.js runtime are installed from their vendors' own package repositories, and those origins are added to the unattended upgrades policy, so they keep receiving updates alongside the Ubuntu archive.
  • Trademark. "Docker" is a trademark of Docker, Inc., used here only to describe the container runtime and registries this software monitors. cloudimg is not affiliated with, endorsed by, or sponsored by Docker, Inc.

Support

cloudimg provides 24/7 support for this image. Contact support through the details on the Azure Marketplace listing, or visit www.cloudimg.co.uk. WUD is open source software licensed under the MIT licence; the upstream project is at github.com/getwud/wud.