Docker Watchers
Watchers are responsible for scanning Docker containers.

The docker watcher lets you configure the Docker hosts you want to watch.
Edit a watcher schedule in the UI
This editor is new in v1.8.0-rc.1 builds and requires a writable configuration file.
Open Watchers, select a watcher, then choose Edit schedule in its detail
panel. This editor changes four settings for existing controller-local watchers:
cron, maintenancewindow, maintenancewindowtz, and maintenancewindowscope.
It requires a mounted configuration file and an authorized session. Agent
watchers, environment-owned values and referenced values are read-only. The
panel shows whether each field comes from the file, environment, a reference or
a default. An unavailable API or file disables editing; the UI does not create
a configuration file.
Save sends only changed fields through PATCH /api/v1/config/editor/watchers.
Remove file value / use default explicitly removes that setting from YAML.
For the optional maintenance window, removal leaves no window configured. An
empty text value is instead submitted as a value and checked by the server's
startup Joi validation. Cancel or closing the panel before Save sends no
write. There is no autosave or background snapshot polling.
The editor loads a revision with its snapshot. A stale revision or a field that has become read-only produces a conflict, retains the draft and disables Save. Use Discard draft and reload to explicitly replace the draft with a fresh snapshot. The UI never retries a save with a newer revision automatically. After saving, it distinguishes a file saved and applied live from a saved file whose reload or audit reported problems. Reload counts, errors, restart fields and orphaned notification-rule references are shown when reported. An uncertain network outcome requires reloading before another change.
Untouched credentials, _file references, interpolation expressions and YAML
comments stay on the server. Referenced fields omit both raw and effective
values from the editor snapshot; the browser never round-trips current secret
values. Connection settings, sockets, credentials, TLS, registry configuration
and agent configuration remain outside this editor. Other configuration panels
and label-backed settings are not made editable by this four-field feature.
Variables
| Env var | Required | Description | Supported values | Default value when missing |
|---|---|---|---|---|
DD_WATCHER_{watcher_name}_CAFILE | ⚪ | CA pem file path (only for TLS connection) | ||
DD_WATCHER_{watcher_name}_AUTH_BEARER | ⚪ | Bearer token for remote Docker API auth (HTTPS only) | ||
DD_WATCHER_{watcher_name}_AUTH_INSECURE | ⚪ | Allow fail-open remote auth fallback when auth is invalid or non-HTTPS | true, false | false |
DD_WATCHER_{watcher_name}_AUTH_PASSWORD | ⚪ | Password for remote Docker API basic auth (HTTPS only) | ||
DD_WATCHER_{watcher_name}_AUTH_TYPE | ⚪ | Auth mode for remote Docker API auth | BASIC, BEARER, OIDC | auto-detected from provided credentials |
DD_WATCHER_{watcher_name}_AUTH_USER | ⚪ | Username for remote Docker API basic auth (HTTPS only) | ||
DD_WATCHER_{watcher_name}_CERTFILE | ⚪ | Certificate pem file path (only for TLS connection) | ||
DD_WATCHER_{watcher_name}_CRON | ⚪ | Scheduling options | Valid CRON expression | 0 */6 * * * (every 6 hours) |
DD_WATCHER_{watcher_name}_DISCOVERY_SETTLE_MS | ⚪ | How long a newly-discovered container must remain visible before drydock registers it (store/triggers/UI) — rides out Docker's brief rename-alias window during container recreation. 0 disables settling entirely | integer ms (>= 0) | 30000 (30 seconds) |
DD_WATCHER_{watcher_name}_HOST | ⚪ | Docker hostname or ip of the host to watch | ||
DD_WATCHER_{watcher_name}_JITTER | ⚪ | Jitter in ms applied to the CRON to better distribute the load on the registries (on the Hub at the first place) | > 0 | 60000 (1 minute) |
DD_WATCHER_{watcher_name}_KEYFILE | ⚪ | Key pem file path (only for TLS connection) | ||
DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW | ⚪ | Schedule during which drydock may install updates on its own (manual updates are never gated). Matched per minute, so use * in the minute field, see Maintenance window | Valid CRON expression | |
DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW_SCOPE | ⚪ | What the window holds back: only automatic installs, or the whole scheduled scan, see Maintenance window | install, scan | install |
DD_WATCHER_{watcher_name}_MAINTENANCE_WINDOW_TZ | ⚪ | Timezone used to evaluate MAINTENANCE_WINDOW | IANA timezone (e.g. UTC, Europe/Paris) | UTC |
DD_WATCHER_{watcher_name}_MATURITY_MODE | ⚪ | Default maturity policy for containers discovered by this watcher; a container label or UI/API override can replace it | all, mature | all (no gate) |
DD_WATCHER_{watcher_name}_MATURITY_MIN_AGE_DAYS | ⚪ | Default minimum image age when MATURITY_MODE=mature; a container label or UI/API override can replace it | integer (1 to 365) | 7 |
DD_WATCHER_{watcher_name}_MATURITY_SKIP_RELEASES | ⚪ | v1.8.0-rc.1: skip the first N distinct stable releases in each candidate family when mature mode is enabled | integer (0 to 100) | 0 (disabled) |
DD_WATCHER_{watcher_name}_MATURITY_RELEASE_FALLBACK_DAYS | ⚪ | v1.8.0-rc.1: optional age fallback for unavailable or insufficient release evidence; the day minimum still applies | integer (0 to 365) | 0 (disabled) |
DD_WATCHER_{watcher_name}_PORT | ⚪ | Docker port of the host to watch | 2375 | |
DD_WATCHER_{watcher_name}_PROTOCOL | ⚪ | Docker remote API protocol | http, https | http |
DD_WATCHER_{watcher_name}_SOCKET | ⚪ | Docker socket to watch | Valid unix socket | /var/run/docker.sock |
DD_WATCHER_{watcher_name}_TAG_FAMILY | ⚪ | Default actionable tag-family policy for this watcher; imgsets and labels can override it | strict, loose | strict |
DD_WATCHER_{watcher_name}_TAG_PIN_INFO | ⚪ | Default for computing informational updateInsight on pinned containers; imgsets and labels can override it | true, false | true |
DD_WATCHER_{watcher_name}_WATCHALL | ⚪ | Include containers in every state (created, paused, exited, restarting, etc.) — not just running ones | true, false | false |
DD_WATCHER_{watcher_name}_WATCHBYDEFAULT | ⚪ | Watch containers that don't have an explicit dd.watch label | true, false | true |
DD_WATCHER_{watcher_name}_WATCHEVENTS | ⚪ | If drydock must monitor docker events | true, false | true |
DD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_* | ⚪ | Shared per-image defaults (image match + include/exclude/transform/link/display/trigger/lookup) | See Image Set Presets section below |
WATCHALL and WATCHBYDEFAULT are independent and operate at different stages. WATCHALL controls which container states Docker returns (running-only vs all states). WATCHBYDEFAULT controls whether unlabeled containers are watched — the dd.watch label always takes precedence when set. See the behavior matrix below for details.
DD_WATCHER_{watcher_name}_WATCHDIGEST and WATCHATSTART settings were removed in v1.6 and remain rejected. Use the dd.watch.digest container label for per-container digest watching or the new process-wide default below; startup scans are always scheduled.Digest-watch default
Set DD_DEFAULTS_WATCH_DIGEST=false to turn digest watching off by default for every Docker watcher owned by this Drydock process. Set dd.watch.digest=true on the containers that should still detect same-tag image rebuilds. true enables digest watching by default, and omitting the setting preserves automatic detection. Only true and false are accepted; an invalid or empty value prevents startup.
Precedence, highest first: the dd.watch.digest container label, a matching image set's WATCH_DIGEST, DD_DEFAULTS_WATCH_DIGEST, then automatic detection. Explicit false values remain overrides, even when the process default is true. Turning digest watching off also affects digest-pinned references; tag selection rules are unchanged.
The equivalent drydock.yml setting is:
defaults:
watch:
digest: falseThe environment overrides the file. Restart Drydock after changing this default; it is not a live watcher-editor field. Existing stored containers pick up the default on their next discovery refresh.
Agent watchers use their agent process's configuration. Set the same variable or YAML setting on each Drydock agent that should use the default; setting it on the controller does not override remote agents' configuration. Native Portwing watchers continue to use Portwing's own configuration.
Watcher frequency and registry rate limits
The default cron is 0 */6 * * * (every 6 hours). With many watched containers and tag lists that paginate to thousands of entries (a single GHCR image can take 20+ paginated calls), polling more often can saturate anonymous Docker Hub limits (100 pulls / 6h) and trigger 429 responses. Drydock honors Retry-After on 429s and rate-limits its own outbound calls with a per-host token bucket. Set DD_WATCHER_{watcher_name}_CRON to change the interval — use 0 * * * * for near-real-time detection or 0 1 * * * for once-daily polling. An explicit CRON you set is always preserved.
Notes:
- If no watcher is configured, a default one named
localwill be automatically created (reading the Docker socket). To suppress this default watcher (e.g. when running as a controller-only node for remote agents), setDD_LOCAL_WATCHER=false. On the next restart the controller drops the container records its own watchers used to own, so a container that has moved to an agent is reported by that agent instead of staying stuck on what the controller last saw. Anything set on those records from the UI travels with the container: a snooze, a maturity mode and skipped tags or digests are stashed under the container's Docker id and land on the agent's copy when it reports in. - Multiple watchers can be configured (if you have multiple Docker hosts to watch). Give them different names.
- Socket configuration and host/port configuration are mutually exclusive.
- When
MAINTENANCE_WINDOWis configured, drydock keeps scanning on itsCRONand defers only the automatic install, which it applies as soon as the window opens. SetMAINTENANCE_WINDOW_SCOPE=scanto skip the scan outside the window instead, which is what releases before v1.8.0 did. Either way the window gates drydock's own automatic behavior only. Manual updates from the UI or API always run. See Maintenance window. wud.*labels are ignored as of v1.6. To rewrite old compose files, runnode dist/index.js config migrate --dry-runthennode dist/index.js config migrate --file <path>.- If watcher logger initialization fails, drydock automatically falls back to structured stderr JSON logs and increments
dd_watcher_logger_init_failures_total{type,name}. Alert on non-zero increases to catch degraded logging early. - The watcher fan-out is capped at 10 concurrent
watchContainerinvocations per cron cycle. On inventories with 40–200 containers this prevents a burst of simultaneous registry calls that would trigger rate limits despite the token bucket. - Hybrid
image:tag@sha256:digestreferences (e.g.docker.io/valkey/valkey:9@sha256:bcf6…) are handled correctly: drydock preserves both the tag and the digest rather than falling back to digest-only mode, so the human-readable tag is displayed in the UI and the registry router resolves the provider cleanly. - Label-derived watcher fields (
tagFamily,tagPinInfo,includeTags,excludeTags,transformTags,triggerInclude,triggerExclude) are re-derived on Docker update events (e.g. afterdocker compose up -d). Labels set on the recreated container take effect immediately without waiting for the next cron cycle; removing a tag-policy label restores its imgset/watcher default. - Floating semver aliases (e.g.
3.3and3.3.0, which both coerce to the same semver) are excluded from the greater-than check in candidate ranking, so aliases that resolve identically are never reported as upgrades. - Fully-pinned specific tags (3+ numeric segments, e.g.
1.4.5,v1.13.3) also compare by digest by default, the same as floating tags, so a rebuilt image republished under the same tag is detected. Version climbing across tags for a pinned image is opt-in viadd.tag.include,dd.tag.family=loose, or the actionable watcher defaultDD_WATCHER_{watcher_name}_TAG_FAMILY=loose. The watcher-wideloosesetting can make cross-version candidates actionable and therefore eligible for configured action triggers; use it deliberately. Drydock separately computes an informationalupdateInsight— the best newer same-family tag available in the registry — which never triggers an update or notification and never changesupdateAvailable/updateKind. The UI renders that insight as an informational Major/Minor/Patch state rather than Current, but does not expose an update action; the pinned tag itself is indicated separately by a persistent pin glyph on the Tag cell, whose tooltip namesdd.tag.family=loose/dd.tag.includeas the remedy. Control the informational channel withdd.tag.pin.info, matching-imgsetTAG_PIN_INFO, or the watcher defaultTAG_PIN_INFO. - A Major insight is not automatically a mismatch: strict family matching preserves prefix, suffix/variant shape, numeric precision, and CalVer rules, but it does not impose a same-major ceiling. By contrast, a Major result shown as an actionable update means an actionable policy such as
dd.tag.family=looseordd.tag.includeis in effect. The Immichv3.0.2 Majorreport in #498 was caused bydd.tag.family=looseon only the machine-learning container. Loose mode still preserves suffix variants — for example,v2.7.5-openvinocan climb tov3.0.2-openvino, never barev3.0.2— and that historical cross-suffix bug is regression-covered. - As of v1.8 (#1133), a candidate tag has to carry the same variant suffix as the current tag, letter for letter:
-alpinenever climbs to-alpine-perlor-alpine-slim, and it doesn't climb the other way either. Digit runs inside a suffix count as precision, not identity, so1.27-alpine3.20can climb to1.28-alpine3.21,12.1-cuda12can climb to12.2-cuda13, and a bare-alpinecan refine to-alpine3.21when that's the only newer build published. When both an exact-suffix and a digit-shifted candidate exist, the exact suffix ranks first. A trailing dot or other punctuation-only difference is never the same family, so1.3.0-alpine.is not1.3.0-alpine. Users who need a digit to be part of the identity, for example pinning to-cuda12, should usedd.tag.includewith a regex that fixes that digit.
Before you start — important watcher caveats:
- Socket vs remote API — When using socket configuration, mount the Docker socket on your drydock container. When using host/port configuration, enable the Docker remote API. If the remote API is secured with TLS, mount and configure the TLS certificates.
- Remote auth is fail-closed — Use HTTPS (
PROTOCOL=https) for remote watcher auth (AUTH_*). Mounted TLS files provide mTLS material and also satisfy the auth safety check, but they do not replace the watcher protocol setting. SetAUTH_INSECURE=trueonly if you intentionally need legacy fail-open behavior. - Digest watching and Hub quotas — Watching image digests causes usage of the Docker Registry Pull API, which is restricted by Quotas on the Docker Hub. By default, drydock enables digest watching for both mutable floating tags (
latest,stable,v3,16-alpine,10.6) and fully-pinned specific tags (1.4.5), so those tags update in place and same-tag rebuilds are detected without the container being moved to a different tag. Version climbing across tags for a pinned image is opt-in viadd.tag.includeordd.tag.family=loose. You can force or disable digest watching per container using thedd.watch.digestlabel or per image withIMGSET_*_WATCH_DIGEST. If you face quota related errors, consider slowing down the watcher rate by adjusting theDD_WATCHER_{watcher_name}_CRONvariable.
Variable examples
Watch the local docker host every day at 1am
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_LOCAL_CRON=0 1 * * *Watch all containers regardless of their status (created, paused, exited, restarting, running...)
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_LOCAL_WATCHALL=trueWATCHALL / WATCHBYDEFAULT behavior matrix
These two variables are orthogonal — they filter at different stages of the container selection pipeline:
- WATCHALL (Docker API level) — decides which container states are fetched from Docker.
- WATCHBYDEFAULT (per-container level) — decides whether containers without a
dd.watchlabel are watched. An explicitdd.watchlabel always takes precedence.
WATCHALL | WATCHBYDEFAULT | Containers fetched | Unlabeled containers | dd.watch=true | dd.watch=false |
|---|---|---|---|---|---|
false (default) | true (default) | Running only | Watched | Watched | Not watched |
false | false | Running only | Not watched | Watched | Not watched |
true | true | All states | Watched | Watched | Not watched |
true | false | All states | Not watched | Watched | Not watched |
dd.watch=true will not be watched when WATCHALL is false because it is never returned by the Docker API in the first place. Set WATCHALL=true if you need to monitor non-running containers.Watch a remote docker host via TCP on 2375
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_MYREMOTEHOST_HOST=myremotehostWatch a remote docker host behind HTTPS with bearer auth
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_MYREMOTEHOST_HOST=myremotehost
- DD_WATCHER_MYREMOTEHOST_PORT=443
- DD_WATCHER_MYREMOTEHOST_PROTOCOL=https
- DD_WATCHER_MYREMOTEHOST_AUTH_TYPE=BEARER
- DD_WATCHER_MYREMOTEHOST_AUTH_BEARER=my-secret-tokenWatch a remote docker host behind HTTPS with OIDC auth
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_MYREMOTEHOST_HOST=myremotehost
- DD_WATCHER_MYREMOTEHOST_PORT=443
- DD_WATCHER_MYREMOTEHOST_PROTOCOL=https
- DD_WATCHER_MYREMOTEHOST_AUTH_TYPE=OIDC
- DD_WATCHER_MYREMOTEHOST_AUTH_OIDC_TOKENURL=https://idp.example.com/oauth/token
- DD_WATCHER_MYREMOTEHOST_AUTH_OIDC_CLIENTID=my-client-id
- DD_WATCHER_MYREMOTEHOST_AUTH_OIDC_CLIENTSECRET=my-client-secretDrydock requests a client_credentials token from AUTH_OIDC_TOKENURL and refreshes it automatically before it expires. Optional variables narrow or extend the request: AUTH_OIDC_SCOPE, AUTH_OIDC_AUDIENCE, and AUTH_OIDC_RESOURCE scope the token; AUTH_OIDC_GRANTTYPE overrides the auto-detected grant type; AUTH_OIDC_DEVICEURL switches to the device-authorization flow.
Watch a remote docker host via TCP with TLS enabled on 2376
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_MYREMOTEHOST_HOST=myremotehost
- DD_WATCHER_MYREMOTEHOST_PORT=2376
- DD_WATCHER_MYREMOTEHOST_CAFILE=/certs/ca.pem
- DD_WATCHER_MYREMOTEHOST_CERTFILE=/certs/cert.pem
- DD_WATCHER_MYREMOTEHOST_KEYFILE=/certs/key.pem
volumes:
- /my-host/my-certs/ca.pem:/certs/ca.pem:ro
- /my-host/my-certs/cert.pem:/certs/cert.pem:ro
- /my-host/my-certs/key.pem:/certs/key.pem:roConnecting via SSH
Drydock does not currently support the ssh:// protocol for remote Docker connections. The PROTOCOL variable only accepts http or https.
If your remote Docker host is only reachable over SSH, you can use an SSH tunnel to forward the remote Docker socket to a local TCP port or Unix socket, then point Drydock at that forwarded endpoint:
# Forward the remote Docker socket to a local TCP port
ssh -nNT -L 2375:/var/run/docker.sock user@remote-hostThen configure Drydock to connect to the forwarded port:
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_MYREMOTEHOST_HOST=host.docker.internal
- DD_WATCHER_MYREMOTEHOST_PORT=2375
extra_hosts:
- "host.docker.internal:host-gateway"Docker Socket Security
Drydock needs to communicate with the Docker Engine API to monitor containers and (optionally) perform updates. By default this means mounting the Docker socket — which grants broad access to the host. This section covers all available approaches to secure that access, from the recommended socket proxy to remote TLS connections.
Security comparison
| Approach | Attack surface | Privilege level | Setup complexity | Auto-updates? |
|---|---|---|---|---|
| Socket proxy (recommended) | Filtered API only | Non-root | Low | Yes (with POST=1) |
| Remote Docker over TLS | Network + TLS | Non-root | Medium | Yes |
| Rootless Docker | Full API, unprivileged daemon | Non-root | Medium | Yes |
| Direct socket mount | Full Docker API | Root | Trivial | Yes |
| Break-glass root mode | Full Docker API + host root | Root | Trivial | Yes |
Option 1: Socket proxy (recommended)
A socket proxy runs as a separate container with access to the Docker socket and exposes only the API endpoints Drydock needs. Drydock connects to the proxy over HTTP, so no socket mount is required at all.
This is the recommended approach for all deployments. It provides a strict security boundary with minimal setup.
services:
drydock:
image: codeswhat/drydock
depends_on:
socket-proxy:
condition: service_healthy
environment:
- DD_WATCHER_LOCAL_HOST=socket-proxy
- DD_WATCHER_LOCAL_PORT=2375
ports:
- 3000:3000
socket-proxy:
image: tecnativa/docker-socket-proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- CONTAINERS=1
- IMAGES=1
- EVENTS=1
- SERVICES=1
- INFO=1 # required for daemon hostname detection (notification identity)
healthcheck:
test: wget --spider http://localhost:2375/version || exit 1
interval: 5s
timeout: 3s
retries: 3
start_period: 5s
restart: unless-stoppedThe :ro (read-only) flag on the Docker socket mount is omitted intentionally. The proxy's environment variables (CONTAINERS, EVENTS, etc.) control which API endpoints are exposed — that is the actual security boundary. The :ro flag on Unix sockets does not prevent API communication and can cause connection failures on some Linux kernels.
Proxy permissions by feature
| Feature | Required proxy env vars |
|---|---|
| Watch containers (default) | CONTAINERS=1, IMAGES=1, EVENTS=1, SERVICES=1 |
| Notification identity (daemon hostname) | All of the above plus INFO=1 — required so drydock can read the Docker daemon's hostname for notification prefixes (e.g. [datavault]). Without this, notifications fall back to os.hostname() which inside a container resolves to the container's short ID. Alternative: set DD_SERVER_NAME on the drydock container to override detection entirely. |
| Container actions (start/stop/restart) | All of the above plus POST=1 and a Docker trigger |
| Docker trigger (auto-updates) | All of the above plus POST=1, NETWORKS=1 |
DD_ACTION_DOCKER_{name}_AUTO=false. Without a Docker trigger, the UI will show "No docker trigger found for this container" when attempting actions.Static IPs and MAC addresses (macvlan, custom networks): recreating a container attached to more than one network requires Drydock to call POST /networks/{id}/connect for each additional network after the replacement container is created. With Tecnativa/docker-socket-proxy, both POST=1 and NETWORKS=1 must be set for these multi-network recreates — NETWORKS=1 alone only covers reads. If you're using sockguard instead, it denies endpoint configs that specify a static IP or MAC address unless request_body.network.allow_endpoint_config: true is set in its policy — and in sockguard v1.5.0 and later (forthcoming at the time of this release), that gate applies to POST /containers/create as well as POST /networks/{id}/connect, so single-network macvlan/static-IP containers need the policy set too, not just multi-network ones (older sockguard versions enforce the policy only at network-connect time).
Alternative socket proxies
Tecnativa/docker-socket-proxy is the most widely used option, but any HAProxy or nginx-based Docker socket proxy that filters by HTTP method and path will work. The key requirement is that the proxy exposes the Docker Engine API endpoints listed in the permissions table above and blocks everything else (especially exec, build, and swarm endpoints).
Other compatible proxies include linuxserver/docker-socket-proxy (a fork with additional features).
Option 2: Remote Docker over TLS
Instead of mounting the socket at all, Drydock can connect to a remote Docker host over the network using mutual TLS (mTLS). This is ideal when you need a single Drydock instance to reach a remote Docker daemon directly, or want to avoid socket mounts entirely. For monitoring containers across multiple independent hosts from one UI, consider Agents, which don't require exposing the Docker API over the network.
Step 1: Generate TLS certificates on the Docker host
Follow the official Docker TLS guide to generate a CA, server certificate, and client certificate.
Step 2: Configure the Docker daemon
Configure the daemon to listen on a TLS port by adding this to /etc/docker/daemon.json:
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlscacert": "/etc/docker/ca.pem",
"tlscert": "/etc/docker/server-cert.pem",
"tlskey": "/etc/docker/server-key.pem",
"tlsverify": true
}Step 3: Configure Drydock
Configure Drydock with the client certificates:
services:
drydock:
image: codeswhat/drydock
environment:
- DD_WATCHER_REMOTE_HOST=docker-host.example.com
- DD_WATCHER_REMOTE_PORT=2376
- DD_WATCHER_REMOTE_CAFILE=/certs/ca.pem
- DD_WATCHER_REMOTE_CERTFILE=/certs/client-cert.pem
- DD_WATCHER_REMOTE_KEYFILE=/certs/client-key.pem
volumes:
- ./certs/ca.pem:/certs/ca.pem:ro
- ./certs/client-cert.pem:/certs/client-cert.pem:ro
- ./certs/client-key.pem:/certs/client-key.pem:ro
ports:
- 3000:3000When TLS certificates are provided (CAFILE, CERTFILE, KEYFILE), Drydock automatically uses HTTPS regardless of the PROTOCOL setting.
:ro) and restrict file permissions to the Drydock user.Option 3: Rootless Docker
Rootless Docker runs the Docker daemon entirely in user space without root privileges. Even full socket access does not grant host root because the daemon itself is unprivileged.
Step 1: Install rootless Docker following the official guide.
Step 2: Find your rootless socket path:
echo $XDG_RUNTIME_DIR/docker.sock
# Typically: /run/user/1000/docker.sockStep 3: Mount the rootless socket:
services:
drydock:
image: codeswhat/drydock
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
ports:
- 3000:3000The rootless socket path varies by user. Replace 1000 with your user's UID (id -u).
--privileged containers, limited network configuration, and some storage drivers may not be available. Check the official limitations list for your use case.You can combine rootless Docker with a socket proxy for defense in depth — even if the proxy is compromised, the attacker only gains unprivileged access.
Option 4: Direct socket mount (default)
The simplest approach — mount the Docker socket directly. Drydock runs as a non-root user inside the container, but the socket grants access to the full Docker API.
services:
drydock:
image: codeswhat/drydock
volumes:
- /var/run/docker.sock:/var/run/docker.sock
ports:
- 3000:3000Option 5: Break-glass root mode (least secure)
If the non-root user cannot connect to the socket (common with :ro mounts), you can explicitly opt into root mode. This is a break-glass path and should not be your default. Both flags are required; setting only DD_RUN_AS_ROOT=true fails closed at startup.
services:
drydock:
image: codeswhat/drydock
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DD_RUN_AS_ROOT=true
- DD_ALLOW_INSECURE_ROOT=true
ports:
- 3000:3000Which Docker API endpoints does Drydock use?
Understanding exactly what Drydock accesses helps you configure socket proxies and evaluate your security posture.
Read-only operations (watchers):
| Endpoint | Purpose |
|---|---|
GET /containers/json | List running containers |
GET /containers/{id}/json | Inspect container details and labels |
GET /images/json | List images on the host |
GET /images/{id}/json | Inspect image metadata (tags, digests, architecture) |
GET /events | Stream real-time container lifecycle events |
GET /services/{id} | Inspect Docker Swarm service labels |
Write operations (triggers — only when performing updates):
| Endpoint | Purpose |
|---|---|
POST /images/create | Pull new image versions |
POST /containers/create | Create replacement container |
POST /containers/{id}/start | Start the new container |
POST /containers/{id}/stop | Stop the old container |
POST /containers/{id}/wait | Wait for container removal |
DELETE /containers/{id} | Remove the old container |
DELETE /images/{id} | Prune old images (when enabled) |
POST /networks/{id}/connect | Connect container to additional networks |
If you only need monitoring (no auto-updates), a read-only socket proxy configuration is sufficient.
Podman Quick Start
Podman works with Drydock through Podman's Docker-compatible API socket.
Socket path mapping (podman.sock vs docker.sock)
Use Podman's host socket path, but mount it inside the Drydock container at /var/run/docker.sock so existing Drydock defaults still work. This is still the recommended path — it's the most predictable option and works with every Drydock release.
| Runtime mode | Host socket path | Path inside Drydock container | Drydock watcher variable |
|---|---|---|---|
| Docker | /var/run/docker.sock | /var/run/docker.sock | DD_WATCHER_LOCAL_SOCKET=/var/run/docker.sock |
| Podman rootful | /run/podman/podman.sock | /var/run/docker.sock | DD_WATCHER_LOCAL_SOCKET=/var/run/docker.sock |
| Podman rootless | /run/user/<uid>/podman/podman.sock | /var/run/docker.sock | DD_WATCHER_LOCAL_SOCKET=/var/run/docker.sock |
/var/run/docker.sock, Drydock now probes for one when the local watcher's socket is left at its default and that default path doesn't exist. It checks /run/podman/podman.sock (rootful), then $XDG_RUNTIME_DIR/podman/podman.sock (rootless) when XDG_RUNTIME_DIR is set, and uses the first one it finds. This is a fallback, not a replacement for the mount above — an explicitly configured DD_WATCHER_LOCAL_SOCKET is always used as-is, with no probing.Rootful vs rootless setup
- Rootful Podman socket:
sudo systemctl enable --now podman.socket - Rootless Podman socket:
systemctl --user enable --now podman.socket
Choosing rootful vs rootless:
- Rootless is recommended for production because it runs the entire container stack without root privileges. Use rootful only when you need privileged ports (<1024) or specific kernel features.
- Rootless requires lingering sessions — You must run
loginctl enable-linger <user>so the user systemd instance (and all rootless containers) survives logout. Without it, Podman containers stop when the user session ends.
services:
drydock:
image: codeswhat/drydock
volumes:
- /run/podman/podman.sock:/var/run/docker.sock
environment:
- DD_WATCHER_LOCAL_SOCKET=/var/run/docker.sock
- DD_ACTION_DOCKER_LOCAL_AUTO=false
ports:
- 3000:3000Podman socket proxy notes:
- Rootless proxy path — For rootless Podman with socket proxy, set
SOCKET_PATH=${XDG_RUNTIME_DIR}/podman/podman.sockand mount the same host path into the proxy container. - 503 errors — Some users have reported intermittent
503errors when usingtecnativa/docker-socket-proxywith Podman (tecnativa/docker-socket-proxy#66). If you experience this, prefer direct socket mount over the proxy — the direct mount examples above are the most reliable option with Podman. - Docker Compose trigger compatibility — The Docker Compose trigger (
DD_ACTION_DOCKERCOMPOSE_*) works with Podman — it inherits the socket connection from your watcher configuration. No additional Podman-specific trigger settings are needed.
Known limitations and tested versions
| Topic | Status |
|---|---|
| Tested Podman version | 5.6.0 on Rocky Linux 9.7 (issue #152) |
| API version negotiation | Drydock probes /version at startup and pins Dockerode to the reported API version, preventing EAI_AGAIN crashes caused by Podman returning HTTP 301 for unversioned API paths. |
| Pod infra containers | Podman pod infrastructure containers (empty Image field) are automatically skipped — they are never added to the container store or compared against registry tags. |
| CI coverage | No dedicated Podman CI matrix yet |
| Rootless networking | Can differ from Docker bridge behavior; see Podman FAQ |
| SELinux (RHEL/Rocky/Fedora) | Socket mounts need :Z flag; see SELinux FAQ |
podman-compose networking | Pod-based networking breaks service-name DNS; use podman compose instead; see FAQ |
Drydock also detects Podman at watcher startup from the daemon's own /version response and logs a one-time warning naming the version and known limits, so it's obvious from the logs alone that a watcher is talking to Podman rather than Docker.
Known limits:
- Rootless networking — can differ from Docker bridge behavior; see Podman FAQ
- Volume driver differences — Podman's volume drivers don't behave identically to Docker's
- Containers managed by a systemd unit (Quadlet) are recreated by the Docker action outside their unit — the Docker action stops, removes, and recreates a container the same way regardless of what's managing it, so a Quadlet-managed container gets rebuilt outside its unit instead of being restarted through it; exclude systemd-managed containers from actions, or use a command action that restarts the unit instead
For common Podman troubleshooting, see FAQ Podman entries.
Watch 1 local Docker host and 2 remote docker hosts at the same time
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_LOCAL_SOCKET=/var/run/docker.sock
- DD_WATCHER_MYREMOTEHOST1_HOST=myremotehost1
- DD_WATCHER_MYREMOTEHOST2_HOST=myremotehost2Maintenance window
A maintenance window restricts when drydock is allowed to act on an update by itself. That covers every action trigger, not only the ones that install an image: docker, dockercompose and portainer apply the update, and command runs the shell command you attached to it. Notification triggers are never gated, so drydock still tells you about an update the moment it finds one.
Outside the window the action is deferred rather than dropped, and drydock arms a catch-up that checks once a minute. When the window opens the catch-up re-runs the watcher's scan, so a trigger in simple or batch mode acts on that scan. A trigger in digest mode is told the window opened and flushes the updates it deferred right then, rather than holding them until its next DIGESTCRON.
By default the window has no effect on looking. Scheduled scans keep running on the watcher's own CRON, so new containers still appear, container state in the UI stays current, and update notifications still go out at the moment an update is found rather than hours later. MAINTENANCE_WINDOW_SCOPE chooses between that and the older whole-scan behaviour:
MAINTENANCE_WINDOW_SCOPE | What the window holds back |
|---|---|
install (default) | Automatic installs only. Discovery, registry checks, container-state refresh and update notifications all run on the watcher's CRON regardless of the window. A deferred install is applied when the window opens. |
scan | The whole scheduled scan. Outside the window nothing is looked at: no discovery, no state refresh, no notifications. One pending scan is queued and replayed when the window opens. |
Set MAINTENANCE_WINDOW_SCOPE=scan to restore the behaviour of releases before v1.8.0.
The hold applies to a container watched by a remote agent the same way it applies to one on the controller's own watchers: the controller reads the window state the agent reports and holds the automatic install back until the agent's next report shows the window open.
The window never applies to you. Manual updates from the UI or API are not gated by it — the container's Update action stays available at any hour. When you use it outside the window, the confirm dialog notes that the update is currently policy-blocked with "Outside maintenance window — auto update deferred until the window opens" and the button reads Update anyway. That is a heads-up, not a refusal. See Update Eligibility & Blockers.
A manual recheck is not gated. The recheck-all action on the containers view, or calling POST /api/v1/containers/watch (all watchers) or POST /api/v1/containers/{id}/watch (a single container) directly, runs immediately regardless of the window. Those calls go straight to the watcher's watch() method, which the maintenance-window check never sits in front of. This is the watcher recheck, not the security Scan now action, which calls /scan and is unrelated to the window. Only the scheduled cron tick (and the queued catch-up it leaves behind) is gated.
Only allow automatic updates between 2 AM and 4 AM in Europe/Berlin:
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_LOCAL_CRON=0 * * * *
- DD_WATCHER_LOCAL_MAINTENANCE_WINDOW=* 2-3 * * *
- DD_WATCHER_LOCAL_MAINTENANCE_WINDOW_TZ=Europe/BerlinPut a `*` in the minute field
MAINTENANCE_WINDOW is not a start/end range. Drydock evaluates the expression once per minute and the window is open only during the minutes the cron matches. So 0 2-3 * * * is open for exactly two minutes a day, 02:00 and 03:00, not from 2 AM to 4 AM. Use * in the minute field to keep the window open for whole hours:
| Expression | Window is open |
|---|---|
* 2-3 * * * | every minute from 02:00 to 03:59 |
*/5 2-3 * * * | every 5th minute from 02:00 to 03:55 |
* 2-3 * * 6,0 | 02:00 to 03:59 on Saturday and Sunday |
0 2-3 * * * | 02:00 and 03:00 only, one minute each |
A minute-precise expression still works if your watcher CRON ticks on exactly those minutes, but the container list and the update dialog report the window as closed the rest of the time, which is confusing. Prefer the * form.
The evaluated timezone is MAINTENANCE_WINDOW_TZ, defaulting to UTC — not the host clock or your browser. The dashboard shows which watchers have a window and when the next one opens.
dd_watcher_maintenance_deferred_updates_total{type,name} counts the automatic updates the window held back, one per container per dispatch evaluation, including a container held back because something it depends on was. dd_watcher_maintenance_skip_total{type,name} keeps its old meaning, one per skipped scan cycle, so it only moves under MAINTENANCE_WINDOW_SCOPE=scan.
Image Set Presets
Use IMGSET to define reusable defaults by image reference. This is useful when many containers need the same tag filters, link template, icon, or trigger routing.
Looking for ready-to-copy presets for common containers? See Popular IMGSET Presets.
Supported imgset keys
DD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_IMAGEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_TAG_INCLUDEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_TAG_EXCLUDEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_TAG_TRANSFORMDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_LINK_TEMPLATEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_DISPLAY_NAMEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_DISPLAY_ICONDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_TRIGGER_INCLUDEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_TAG_FAMILYDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_TAG_PIN_INFODD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_WATCH_DIGESTDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_INSPECT_TAG_PATHDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_TRIGGER_EXCLUDEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_REGISTRY_LOOKUP_IMAGEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_REGISTRY_LOOKUP_URLDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_INCLUDEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_EXCLUDEDD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_TRANSFORM
INCLUDE/EXCLUDE/TRANSFORM are shorthand aliases for TAG_INCLUDE/TAG_EXCLUDE/TAG_TRANSFORM — prefer the TAG_ form since it matches the dd.tag.* label naming used everywhere else. REGISTRY_LOOKUP_URL is an alias for REGISTRY_LOOKUP_IMAGE.
Imgset precedence
dd.*labels on the container (or swarm service/container merged labels) are highest priority.- Matching
IMGSETvalues apply when the corresponding label is not set. - Watcher-level
TAG_FAMILYandTAG_PIN_INFOvalues apply when neither a label nor imgset sets that policy. - Built-in defaults are
strictfor tag family andtruefor pin insight.
Imgset example
services:
drydock:
image: codeswhat/drydock
environment:
- DD_WATCHER_LOCAL_IMGSET_HOMEASSISTANT_IMAGE=ghcr.io/home-assistant/home-assistant
- DD_WATCHER_LOCAL_IMGSET_HOMEASSISTANT_TAG_INCLUDE=^\\d+\\.\\d+\\.\\d+$$
- DD_WATCHER_LOCAL_IMGSET_HOMEASSISTANT_DISPLAY_NAME=Home Assistant
- DD_WATCHER_LOCAL_IMGSET_HOMEASSISTANT_DISPLAY_ICON=hl-home-assistant
- DD_WATCHER_LOCAL_IMGSET_HOMEASSISTANT_LINK_TEMPLATE=https://www.home-assistant.io/changelogs/core-$${major}$${minor}$${patch}
- DD_WATCHER_LOCAL_IMGSET_HOMEASSISTANT_TRIGGER_INCLUDE=ntfy.default:majorLabels
To fine-tune the behaviour of drydock per container, you can add labels on them.
| Label | Required | Description | Supported values | Default value when missing |
|---|---|---|---|---|
dd.display.icon | ⚪ | Custom display icon for the container | Valid Fontawesome Icon, Homarr Labs Icon, Selfh.st Icon, or Simple Icon (see details below). mdi: icons are auto-resolved but not recommended. | fab fa-docker |
dd.display.picture | ⚪ | Custom entity picture URL for Home Assistant MQTT integration. When set to an HTTP/HTTPS URL, overrides the icon-derived entity_picture in HASS discovery payloads. | Valid HTTP or HTTPS URL | |
dd.display.name | ⚪ | Custom display name for the container | Valid String | Container name |
dd.port.label | ⚪ | Custom label for a specific published port, shown on the clickable port links in the UI's container list and detail views | Comma-separated <port>=<label> pairs, e.g. 80=Web UI,443/tcp=Admin Console. Bare port numbers default to /tcp. | Raw port mapping (e.g. 8080->80/tcp) |
dd.group | ⚪ | Durable server-side group name for stack/group views (falls back to com.docker.compose.project, then com.docker.stack.namespace). A browser-only manual override can temporarily supersede this presentation without changing the label. | Valid String | |
dd.inspect.tag.path | ⚪ | Docker inspect path used to derive a local semver tag. The extracted value overwrites the image tag (for update detection) and is written to image.softwareVersion (for the Version column). Use dd.inspect.tag.version-only=true to route it to image.softwareVersion only. | Slash-separated path in docker inspect output | |
dd.inspect.tag.version-only | ⚪ | When dd.inspect.tag.path is set, route the extracted value to image.softwareVersion only instead of overwriting the image tag. Default behavior (tag overwrite) is unchanged when this label is absent or false. | true, false | false |
dd.registry.lookup.image | ⚪ | Alternative image reference used for update lookups | Full image path (for example library/traefik or ghcr.io/traefik/traefik) | |
dd.link.template | ⚪ | Browsable link associated to the container version. A matching imgset's LINK_TEMPLATE is used when this label is absent — see Associate a link to the container version | String template with placeholders ${raw}, ${original}, ${transformed}, ${major}, ${minor}, ${patch}, ${prerelease} | Matching imgset LINK_TEMPLATE → none |
dd.tag.exclude | ⚪ | Regex to exclude specific tags | RE2 syntax, not JavaScript regex; no lookaround or backreferences | |
dd.tag.include | ⚪ | Regex to include specific tags only | RE2 syntax, not JavaScript regex; no lookaround or backreferences | |
dd.tag.transform | ⚪ | Transform function to apply to the tag | $valid_regex => $valid_string_with_placeholders — see the tag transform grammar | |
dd.tag.family | ⚪ | Actionable tag-family policy. Overrides matching imgset and watcher defaults; loose allows cross-tag semver moves such as 16-alpine → 17-alpine and can fire action triggers. Suffix variants must still match exactly, digit runs excepted. | strict or loose | Matching imgset → watcher default → strict |
dd.tag.pin.info | ⚪ | Whether to compute informational newer-tag insight for a pinned tag. This never makes an update actionable. | true, false | Matching imgset → watcher default → true |
dd.updatePolicy.maturityMode | ⚪ | Declarative maturity gate for this container | all, mature | Watcher default → all |
dd.updatePolicy.maturityMinAgeDays | ⚪ | Declarative minimum image age for mature-only updates | integer (1 to 365) | Watcher default → 7 |
dd.updatePolicy.maturitySkipReleases | ⚪ | v1.8.0-rc.1: distinct earlier stable releases required in the candidate family | integer (0 to 100) | Watcher default → 0 |
dd.updatePolicy.maturityReleaseFallbackDays | ⚪ | v1.8.0-rc.1: explicit age fallback for the release-count gate | integer (0 to 365) | Watcher default → 0 |
dd.updatePolicy.skipTags | ⚪ | Declarative comma-separated list of remote tags to suppress | CSV tags | Empty list |
dd.updatePolicy.skipDigests | ⚪ | Declarative comma-separated list of remote digests to suppress | CSV digests | Empty list |
dd.action.exclude | ⚪ | Exclude specific action triggers from this container. An explicit exclude match always wins, even against a more permissive trigger. | $trigger_1_id_or_name,$trigger_2_id_or_name:$threshold | |
dd.action.include | ⚪ | Only allow specific action triggers for this container. Grants manual access always; also grants automatic access, except under a trigger's AUTO=onauto, where automatic access requires a separate dd.action.auto match (see below). Every grant applies only when no dd.action.exclude entry matches the same trigger — an exclusion always wins. | $trigger_1_id_or_name,$trigger_2_id_or_name:$threshold | |
dd.action.auto | ⚪ | Only consulted by a trigger configured with AUTO=onauto (see the trigger _AUTO reference on the Actions & Notifications page). Grants both manual and automatic access for that trigger — matching dd.action.include's grant of manual access — independent of, and with an optionally different threshold than, dd.action.include. No effect under AUTO=all, AUTO=oninclude, or AUTO=none. As with dd.action.include, the grant applies only when no dd.action.exclude entry matches the trigger. | $trigger_1_id_or_name,$trigger_2_id_or_name:$threshold | |
dd.notification.exclude | ⚪ | Exclude specific notification triggers from this container | $trigger_1_id_or_name,$trigger_2_id_or_name:$threshold | |
dd.notification.include | ⚪ | Only allow specific notification triggers for this container | $trigger_1_id_or_name,$trigger_2_id_or_name:$threshold | |
dd.trigger.exclude | ⚪ | Removed in v1.7.0 — no longer resolved; use dd.action.exclude or dd.notification.exclude instead | $trigger_1_id_or_name,$trigger_2_id_or_name:$threshold | |
dd.trigger.include | ⚪ | Removed in v1.7.0 — no longer resolved; use dd.action.include or dd.notification.include instead | $trigger_1_id_or_name,$trigger_2_id_or_name:$threshold | |
dd.watch.digest | ⚪ | Watch this container digest. Missing values are auto-enabled for both mutable floating tags and fully-pinned specific semver tags. | Valid Boolean | Automatic |
dd.watch | ⚪ | Explicitly include or exclude this container from monitoring (overrides WATCHBYDEFAULT) | Valid Boolean | Inherits from WATCHBYDEFAULT (true by default) |
dd.compose.file | ⚪ | Path to the docker-compose.yml file for compose-native updates (also configurable via trigger COMPOSEFILELABEL) | file path | |
dd.source.repo | ⚪ | Source repository override for release-notes lookup (e.g. owner/repo) | Valid owner/repo string | |
dd.webhook.enabled | ⚪ | Allow or block webhook API calls targeting this container | true, false | true |
dd.hook.pre | ⚪ | Shell command to run before a container update | Valid shell command | |
dd.hook.post | ⚪ | Shell command to run after a container update | Valid shell command | |
dd.hook.pre.abort | ⚪ | Abort the update if the pre-hook exits non-zero | Valid Boolean | true |
dd.hook.timeout | ⚪ | Timeout in milliseconds for hook execution | integer | 60000 |
dd.rollback.auto | ⚪ | Keep polling health after the update is finalized and roll back a failure during dd.rollback.window. The initial health gate runs regardless for a running container with a HEALTHCHECK; a disabled healthcheck (healthcheck: disable: true or --no-healthcheck) counts as none | Valid Boolean | false |
dd.rollback.window | ⚪ | Health monitoring window in milliseconds after an update (how long to watch for failures) | integer | 300000 |
dd.rollback.interval | ⚪ | Health polling interval in milliseconds during the rollback window | integer | 10000 |
dd.depends_on | ⚪ | Comma-separated list of container names this container depends on. Overrides compose-file depends_on detection entirely (no merge) — when set, drydock uses exactly this list. Setting it to an empty value declares "no dependencies" and also suppresses compose detection. A self-reference is dropped with a warning. | CSV container names | Detected from the compose file's depends_on when the container is compose-managed; otherwise none |
dd.depends_on.action | ⚪ | What to do with this container's dependents when it updates. Applies to both label-declared and compose-detected dependencies. An unrecognized value falls back to update with a warning. | update, restart | update |
dd.update.mode | ⚪ | Update routing mode for infrastructure containers (socket proxies, etc.) | infrastructure | standard update path |
dd.runtime.entrypoint.origin | ⚪ | Tracks whether the container's entrypoint was explicitly set or inherited from the image. Used during updates to decide whether to preserve the current entrypoint or let the new image defaults apply. | explicit, inherited, unknown | auto-detected |
dd.runtime.cmd.origin | ⚪ | Tracks whether the container's cmd was explicitly set or inherited from the image. Used during updates to decide whether to preserve the current cmd or let the new image defaults apply. | explicit, inherited, unknown | auto-detected |
dd.display.name, dd.display.icon, dd.depends_on, dd.depends_on.action, dd.notification.include, dd.notification.exclude, dd.action.include, dd.action.exclude and dd.action.auto can also be overridden by Drydock itself, per container, without touching the label. See Override labels with the API.
dd.runtime.entrypoint.origin and dd.runtime.cmd.origin are managed automatically by the Docker trigger during container updates. You only need to set them manually if drydock cannot detect the origin (shows unknown) and you want to explicitly pin or release a custom entrypoint/cmd. Image defaults after an update covers how drydock decides.
Dependency-ordered updates
Label-declared dependencies are limited to containers on the same agent and watcher; Compose-detected dependencies resolve service names within the same Compose project and agent. Cross-host dependency updates and restarts are not supported, including a controller on one host coordinating Tdarr nodes on other hosts. A matching target on another agent is reported as crossHostIgnored and is not dispatched; a label target outside the watcher's name scope is unresolved. The dependency UI does not extend this scope. See discussion #219 for the remaining cross-host request.
dd.depends_on and dd.depends_on.action don't just record a relationship — they change dispatch order. Any bulk update (a manual multi-select, a group update, a maintenance-window batch) is partitioned into topological "waves" before dispatch: a container never starts updating until every container it depends on has finished. Dependents configured with dd.depends_on.action=restart are restarted (not re-pulled) once their dependency's update completes; if an earlier wave fails outright, dependents that never got a chance to run land in a skipped-dependency status rather than silently vanishing from the operations list. A dependency cycle is never a deadlock — cycle members are grouped into one unordered wave and dispatched together.
Inspect the resolved graph with GET /api/v1/containers/dependencies, dry-run the wave order for one container's chain with POST /api/v1/containers/{id}/update-chain-preview, or bulk-accept that whole chain with POST /api/v1/dependency-groups/{rootId}/update. In the UI, the "Update dependency chain" action previews the same wave order — including any cycle or unresolved-target warnings — before you confirm.
The container list shows the graph too. A container that depends on another, or that another depends on, gets a chevron next to its name; expanding it lists what it depends on, what requires it, a cycle marker when it sits in one, and an "Update dependency group" button that dispatches the whole connected set in wave order after the same preview. Updating a single container whose dependency also has a pending update warns you first, since updating the child on its own leaves it running against an outdated parent; you can still go ahead.
Each row also gets a checkbox, and the list header has a "select all" checkbox for everything currently visible under your filters. Checking any row opens a floating bar showing how many containers are selected, with buttons to update the selection or clear it. Update selected opens a confirm dialog that sorts your selection into what will update, what's blocked or already updating and why, what has policy warnings you're overriding, what's skipped, and (the same dependency check as above, applied to the whole batch) which of the selected containers depend on a container that also has an update waiting; you can add those parents to the batch from the same dialog instead of updating the child first.
dd.inspect.tag.path is optional and opt-in. Use it only when your image metadata tracks the running app version reliably; some images set unrelated values. By default the extracted value overwrites the image tag (enabling update detection against the embedded semver) and is also written to image.softwareVersion (shown in the Version column). Add dd.inspect.tag.version-only=true when you want the Version column populated without changing how drydock matches updates. Also note that legacy alias dd.registry.lookup.url is still accepted for compatibility, but prefer dd.registry.lookup.image.An invalid dd.tag.transform pattern fails that container's scan, not the app
dd.tag.transform regex patterns are compiled and validated every time a container's image details are refreshed — not once at process startup. A malformed pattern (fails to compile) or an oversized one (over 1024 characters) makes that refresh throw, which drydock catches per container: it logs a Failed to fetch image detail warning naming the pattern and the parse error, and that container's tag/update state is left stale until the label is fixed. It does not crash drydock and does not affect other containers, but it does repeat on every subsequent scan until the label is corrected. See the tag transform grammar for the exact syntax rules and what counts as invalid.org.opencontainers.image.source OCI label, dd.source.repo label, or GHCR-derived source URL is available for a container, drydock shows a clickable "View project" link in the detail panel and container card. Set dd.source.repo=owner/repo on a container to override or supply the source URL when the image does not carry org.opencontainers.image.source.Release notes inline popover — The release-notes icon on each container row opens an inline popover showing both the current and available-version release notes side by side, with expand/collapse per panel. When only an external release URL is available (no structured notes), the popover contains a single row linking out to that URL.
Intermediate releases. When a container is several versions behind, drydock also fetches the releases between the running tag (exclusive) and the update target (inclusive), so you see the full range in one place. This is best-effort and semver-only: both tags must be strict semver (a leading v is fine). Date tags and rolling tags (latest, stable) fall back to the standard two-panel current/target view. The intermediate list is lazy-loaded when the popover opens — it is not embedded in the container model or the agent snapshot, so it adds no ongoing payload weight. Control the cap with DD_RELEASE_NOTES_MAX_INTERMEDIATE (default 20; set to 0 to disable intermediate fetching entirely). When the range exceeds the cap, the popover shows a "N older releases not shown" notice. The __FILE secret-file convention is supported (DD_RELEASE_NOTES_MAX_INTERMEDIATE__FILE). The backing endpoint is GET /api/v1/containers/{id}/intermediate-release-notes?from=<tag>&to=<tag> (from required; to defaults to the container's pending update tag).
Token behavior. Release notes are fetched from GitHub. The dedicated DD_RELEASE_NOTES_GITHUB_TOKEN, when configured, is forwarded for all source resolutions: trusted OCI image labels, GHCR image paths, Docker Hub metadata lookups, and container-label sources (dd.source.repo, persisted container.sourceRepo). The GHCR token fallback (see GHCR) stays restricted to trusted sources only and is never sent to a container-label source. Because DD_RELEASE_NOTES_GITHUB_TOKEN can be sent to a repo named by a (potentially attacker-controllable) container label, scope it narrowly. Two safe options: (a) a classic PAT with the public_repo scope only — no other scopes; or (b) a fine-grained PAT with read-only Contents permission limited to public repositories and no write or account permissions.
Rate limiting. A range fetch makes more GitHub API calls than two point lookups. Unauthenticated GitHub is capped at 60 requests per hour, so operators watching third-party images via dd.source.repo should configure DD_RELEASE_NOTES_GITHUB_TOKEN to make intermediate fetching viable.
dd.source.repo container label can shadow a trusted OCI source label
dd.source.repo label directly to a running container (rather than baking it into the image) silently downgrades source resolution from trusted to untrusted when the image already carries a trusted org.opencontainers.image.source (or org.opencontainers.image.url) OCI label. The downgrade means the GHCR token fallback is not used for release-notes lookups on that container. Drydock logs a warn-level line each watch cycle whenever a dd.source.repo container label shadows a trusted OCI source label, naming both repos. If you rely on the GHCR token fallback, remove the dd.source.repo container label and let the OCI label resolve the source repo instead.dd.action.include / dd.notification.include (and their .exclude counterparts) gate both manual and automatic trigger access. For notification triggers this filters which providers fire when an update is detected (e.g. route notifications to specific Slack channels). For action triggers (docker, dockercompose, portainer, command) it gates both which trigger a manual update/run can be routed through and which trigger fires automatically — see the callout below for how AUTO changes that split. Labels match against the trigger name (e.g., alerts) or full ID (e.g., smtp.alerts). Manual updates from the UI additionally use agent-based matching — the update is routed to the agent that discovered the container. The legacy dd.trigger.include / dd.trigger.exclude labels were removed in v1.7.0 and no longer affect trigger routing — see Deprecation Schedule.Portainer is an action trigger, so dd.action.include and dd.action.exclude apply to it like Docker and Docker Compose actions. Portainer still requires its local Compose identity and is never routed through an agent.
AUTO also sets the baseline access default, not just automatic execution
An action trigger's AUTO value (all / oninclude / onauto / none) is not only about whether an update fires automatically — under all and none, it also decides whether dd.action.include is consulted at all:
AUTO=noneopens manual access to every watched container for that trigger, regardless ofdd.action.include— reading the name, you'd expectnoneto mean "closed," but it means "no automatic execution," not "no access." Automatic execution never fires either way.AUTO=all(the historical default before v1.5.0) likewise opens both manual and automatic access to every container, ignoringdd.action.include.AUTO=onincludeandAUTO=onautoare the modes wheredd.action.include(and, underonauto,dd.action.auto) actually gate access. Access is closed by default. On v1.8, an action'sMANUAL=allopens manual access for containers without an explicit include list, while automatic execution still requires the matching label and explicit exclusions still win.
If you're trying to restrict which containers a trigger can touch at all, set AUTO=oninclude or AUTO=onauto and use dd.action.include / dd.action.auto — AUTO=none will not do that.
dd.hook.pre / dd.hook.post) let you run arbitrary shell commands before and after a container update. If dd.hook.pre.abort is true (the default) and the pre-hook exits non-zero, the update is cancelled. Use dd.hook.timeout to control how long the hook is allowed to run before being killed.dd.rollback.auto) monitors the container's health check after an update. If the health check fails within the dd.rollback.window (default 5 minutes, polled every dd.rollback.interval = 10 seconds), drydock automatically rolls the container back to its previous image.dd.update.mode=infrastructure) is for containers that Drydock depends on for its Docker API connection, such as a socket proxy. When set, the container is updated through a helper-container path that connects directly to /var/run/docker.sock, bypassing the proxy so the connection isn't severed mid-update. See Infrastructure Update Mode for details.dd.updatePolicy.* labels above. The same six fields can also be set for a whole group of containers with a group policy, which sits between the labels and the watcher environment. Watcher environment defaults are available for the four maturity fields. snoozeUntil remains UI/API-only because it is an operational timer rather than deployment configuration. When a policy gate fires, it surfaces as a pill on the container row — see Update Eligibility & Blockers for the full reason reference.Override labels with the API
On the v1.8.0-rc.1 candidate, Drydock can store its own value for nine label-owned fields: the display name and icon, dd.depends_on and dd.depends_on.action, and the notification and action include, exclude and auto routing. This is for changing a container's behavior when you can't or don't want to edit its Compose file or recreate it. Saving an override never edits a Compose file, calls Docker, recreates or restarts a container, or runs a trigger. The endpoints and request format are in the Container API.
Each field resolves on its own, from lowest to highest priority:
| Priority | Source | Notes |
|---|---|---|
| 1 (lowest) | Defaults and watcher configuration | Container name, the default icon, image set routing |
| 2 | Compose depends_on | Dependencies only |
| 3 | Docker label | dd.display.name, dd.depends_on and the rest |
| 4 (highest) | Drydock override | Set through the API |
A list override replaces the label's list whole and never merges with it. An empty list is an explicit "none": for dependencies it means no dependencies even when Compose declares some, and for a routing field it removes the label's value instead of falling back to it. Removing an override reveals the current label value again.
An override belongs to the container identity (watcher, agent and name, or the Compose project and service), not to a Docker id. It survives recreating the container, restarting Drydock and updating Drydock, and it has no expiry. For a Compose container the identity is the service, so every replica shares one override. Two limits follow from that. An override still applies to a different container that later takes the same name or Compose service, and the stored row stays visible in GET /api/v1/label-overrides when no container matches. Renaming a plain container, switching it between docker run and Compose, or moving it to another watcher or agent gives it a new identity, so the old override stops applying and has to be set again.
On a traditional agent, the agent checks action routing against its own labels again before it acts, so an override there can only narrow access: dd.action.include and dd.action.auto overrides may only list references the label lists too (none at all when the label has no include), with no wider threshold, and a dd.action.exclude override must start with the label's entries in order and may add more after them. Drydock applies the same rule each time the container is written, so a later label change is never cancelled by a stored override. Portwing controller-transport containers and containers on the controller are not restricted. Notification routing, display and dependency waves always run on the controller, so overrides apply fully to agent containers.
Labels stay what Docker reports. The Labels tab and label search keep showing the raw labels, and an override only changes the effective value Drydock uses. Admission messages say when the include or exclude that blocked an update is an override ("by the Drydock override of dd.action.exclude").
Update-policy precedence
Each field resolves independently. A missing value falls through to the next tier; skip lists replace the lower-tier list rather than being concatenated.
| Priority | Tier | Examples |
|---|---|---|
| 1 (highest) | UI/API override | Update policy editor or PATCH /api/v1/containers/:id/update-policy |
| 2 | Container label | dd.updatePolicy.maturityMode=mature, dd.updatePolicy.skipTags=latest,stable |
| 3 | Group policy | PUT /api/v1/group-policies/:id, stored in Drydock for every member of a group |
| 4 | Watcher environment | DD_WATCHER_LOCAL_MATURITY_MODE, DD_WATCHER_LOCAL_MATURITY_MIN_AGE_DAYS |
| 5 | Built-in | maturityMode=all; minimum age 7 when mature mode is active; empty skip lists |
UI/API overrides persist across Drydock restarts, container recreation, and later label changes. Removing a label does not silently remove an override. The policy editor shows an Overridden badge when an override materially differs from the declarative value; Revert clears the selected override and immediately reveals the current label, group, watcher, or built-in value. Empty skip-list overrides are intentional: Clear Skips can suppress a declarative skip list without deleting the declaration, while Revert restores it.
Label examples
Include specific containers to watch
Set WATCHBYDEFAULT=false so that only containers with an explicit dd.watch=true label are monitored.
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_WATCHER_LOCAL_WATCHBYDEFAULT=falseThen add the dd.watch=true label on the containers you want to watch.
services:
mariadb:
image: mariadb:10.4.5
...
labels:
- dd.watch=trueExclude specific containers to watch
Ensure DD_WATCHER_{watcher_name}_WATCHBYDEFAULT is true (default value).
Then add the dd.watch=false label on the containers you want to exclude from being watched.
services:
mariadb:
image: mariadb:10.4.5
...
labels:
- dd.watch=falseDerive a semver from Docker inspect when image tag is latest
Use this when the running container exposes a version label in docker inspect.
services:
myapp:
image: ghcr.io/example/myapp:latest
labels:
- dd.inspect.tag.path=Config/Labels/org.opencontainers.image.versionShow the app version from inspect without changing update detection
Use dd.inspect.tag.version-only=true when the inspect path value is a display-only version string that would break update detection if used as the image tag (for example when the registry tag and the embedded app version differ in format).
services:
myapp:
image: ghcr.io/example/myapp:latest
labels:
- dd.inspect.tag.path=Config/Labels/org.opencontainers.image.version
- dd.inspect.tag.version-only=trueThe extracted version (e.g. 2.14.3) appears in the Version column. The Tag column continues to show latest and digest-based update detection is unaffected.
Use an alternative image for update lookups
Use this when your runtime image is pulled from a cache/proxy registry, but you want updates checked against an upstream image.
services:
traefik:
image: harbor.example.com/dockerhub-proxy/traefik:v3.5.3
labels:
- dd.watch=true
- dd.registry.lookup.image=library/traefikInclude only 3 digits semver tags
You can filter (by inclusion or exclusion) which versions can be candidates for update.
For example, you can indicate that you want to watch x.y.z versions only
services:
mariadb:
image: mariadb:10.4.5
labels:
- dd.tag.include=^\d+\.\d+\.\d+$$Transform the tags before performing the analysis
In certain cases, tag values are so badly formatted that the resolution algorithm cannot find any valid update candidates or, worst, find bad positive matches.
For example, you can encounter such an issue if you need to deal with tags looking like 1.0.0-99-7b368146, 1.0.0-273-21d7efa6...
By default, drydock will report bad positive matches because of the sha-1 part at the end of the tag value (-7b368146...).
That's a shame because 1.0.0-99 and 1.0.0-273 would have been valid semver values ($major.$minor.$patch-$prerelease).
You can get around this issue by providing a function that keeps only the part you are interested in.
How does it work?
The transform function must follow the following syntax:
$valid_regex_with_capturing_groups => $valid_string_with_placeholdersFor example:
^(\d+\.\d+\.\d+-\d+)-.*$ => $1The capturing groups are accessible with the syntax $1, $2, $3....
$1!Grammar
- Regex flavor is RE2, the same engine used by
dd.tag.include/dd.tag.exclude(see RE2 syntax), not JavaScript regex. RE2 has no lookahead/lookbehind and no backreferences (\1) — those fail to compile. Inline flags like(?i)for case-insensitive matching are supported and are the only way to get case-insensitive matching; matching is case-sensitive by default. - The separator is the first
=>in the formula. Everything before it (trimmed) is the pattern; everything after it (trimmed) is the replacement. Whitespace around=>is optional (pattern=>replacementworks the same aspattern => replacement). Only one pattern/replacement pair is supported per label — there is no chaining or delimiter for multiple transforms. - A formula with no
=>is ignored. The tag passes through unchanged; this is not treated as an error. - An empty or unset
dd.tag.transformis a no-op — the tag passes through unchanged. - Replacement placeholders are
$1,$2,$3... referencing capturing groups in the pattern, in the order they appear (not by name). A placeholder that references a group the pattern doesn't have, or a group that didn't participate in the match, is replaced with an empty string rather than left literal or causing an error. A replacement with no placeholders at all is a valid way to force a constant value (e.g.^v(.+)$ => stable). - When the pattern doesn't match the tag, the original tag is returned unchanged — the replacement is never applied partially.
- Anchoring is up to you. RE2 (like most regex engines) matches anywhere in the string unless you anchor with
^/$; the worked examples below all anchor explicitly, which is the recommended default for a transform since an unanchored pattern can match more of the tag than intended. - Patterns are capped at 1024 characters. Longer patterns fail to compile — see the callout above for what that does at runtime.
| Input tag | dd.tag.transform | Result | Why |
|---|---|---|---|
v1.2.3 | ^v(.+)$ => $1 | 1.2.3 | Strips a leading v; $1 is everything after it. |
1.2.3-99-xyz | ^(\d+\.\d+\.\d+-\d+)-.*$ => $1 | 1.2.3-99 | Keeps the semver-with-prerelease prefix, drops the trailing build/hash segment. |
1.2-xyz-3 | ^(\d+\.\d+)-.*-(\d+) => $1.$2 | 1.2.3 | Two capturing groups recombined into a new value via $1 and $2. |
v1.2.3 | ^v(.+)$ => stable | stable | Replacement with no placeholders — always produces the same constant. |
123 | ^(\d+)$ => $2 | (empty string) | $2 references a group that doesn't exist; missing groups become ''. |
1.2.3 | ^nomatch$ => $1 | 1.2.3 | Pattern doesn't match, so the original tag passes through unchanged. |
1.2.3 | invalid-formula | 1.2.3 | No => in the formula — ignored, original tag passes through. |
v1.2.3 | (?i)^V(.+)$ => $1 | 1.2.3 | Inline (?i) makes the match case-insensitive; without it, ^V would not match a lowercase v. |
[invalid-regex => $1 (unbalanced bracket) or one using lookaround ((?=...)) or a backreference (\1), neither of which RE2 supports — is a configuration error, not a "no match": see the callout above for what happens to that container's scans until the label is fixed.Interaction with include/exclude and semver comparison
dd.tag.include/dd.tag.exclude are matched against the raw tags returned by the registry, before any transform is applied — the transform never changes which tags are considered candidates. dd.tag.transform is applied afterward, to both the current tag and each surviving candidate, and it's the transformed value that gets semver-parsed, family-matched, and compared to decide whether an update is available and what precision (tagPrecision) and family shape the tag has. In other words: use include/exclude to narrow down which tags are in play using their real names, and transform to reshape those tags into something semver-comparable. The ${transformed} variable in dd.link.template is this same post-transform value.
For example, to watch only x.y.z-n-hash-style tags and strip the hash suffix before comparison:
services:
searx:
image: searx/searx:1.0.0-269-7b368146
labels:
- dd.tag.include=^\d+\.\d+\.\d+-\d+-.*$$
- dd.tag.transform=^(\d+\.\d+\.\d+-\d+)-.*$$ => $$1Enable digest watching
In addition to semver tag tracking, drydock can track whether the digest associated with the local tag has been updated. This is enabled automatically for floating tags known to move in place (latest, stable, 10, 10.6, 16-alpine). Add dd.watch.digest=true when you want to force digest tracking for a specific container, or dd.watch.digest=false when you want to opt a high-churn Docker Hub image out.
services:
mariadb:
image: mariadb:10
labels:
- dd.tag.include=^\d+$$
- dd.watch.digest=trueAssociate a link to the container version
You can associate a browsable link to the container version — a GitHub release, a changelog page, a registry tag search — using a templated string. Drydock renders the template once per container, computes it onto container.link and container.result.link, and the UI's release-notes/release-link action surfaces it wherever that container is shown.
Set it with the dd.link.template label, or with DD_WATCHER_{watcher_name}_IMGSET_{imgset_name}_LINK_TEMPLATE on a matching image set preset when many containers of the same image should share one template. A per-container dd.link.template label always wins over a matching imgset's LINK_TEMPLATE — see Imgset precedence.
Placeholders
| Placeholder | Value | Notes |
|---|---|---|
${raw} | The tag as reported by the registry, before dd.tag.transform | Deprecated alias for ${original}, kept for backward compatibility — prefer ${original} in new templates |
${original} | The tag as reported by the registry, before dd.tag.transform | |
${transformed} | ${original} after the optional dd.tag.transform is applied; equal to ${original} when no transform is configured or its regex doesn't match | Same post-transform value used for semver parsing, family matching, and update comparison — see Interaction with include/exclude and semver comparison |
${major} | Major segment of ${transformed} | Empty string unless drydock recognizes the tag as semver-shaped and ${transformed} parses as valid semver |
${minor} | Minor segment of ${transformed} | Same conditions as ${major} |
${patch} | Patch segment of ${transformed} | Same conditions as ${major} |
${prerelease} | First identifier of the prerelease sequence (e.g. 2.0.0-rc.1 → rc) | Empty string when there's no prerelease, or the same conditions as ${major} aren't met |
A placeholder outside this set (e.g. ${bogus}) is silently replaced with an empty string — the template is never rejected and nothing is logged.
Worked examples
GitHub release link from ${major}.${minor}.${patch} — Traefik tags carry a v prefix (v3.1.4) that has to come off before the value parses as semver, so dd.tag.transform strips it and the template rebuilds the release URL from the parsed segments:
docker run -d --name traefik \
--label 'dd.tag.transform=^v(.*)$ => $1' \
--label 'dd.link.template=https://github.com/traefik/traefik/releases/tag/v${major}.${minor}.${patch}' \
traefik:v3.1.4Tag v3.1.4 renders https://github.com/traefik/traefik/releases/tag/v3.1.4.
Changelog link using ${transformed} — Postgres tags carry a variant suffix (16.4-alpine) the changelog URL doesn't use, so the transform trims it to major.minor and the template uses that value directly instead of rebuilding it from ${major}/${minor}:
docker run -d --name postgres \
--label 'dd.tag.transform=^(\d+\.\d+).*$ => $1' \
--label 'dd.link.template=https://www.postgresql.org/docs/release/${transformed}/' \
postgres:16.4-alpineTag 16.4-alpine renders https://www.postgresql.org/docs/release/16.4/.
Registry link using ${raw} — no transform is needed to point at a registry's own tag listing, since the raw tag is exactly what the registry expects in the query string:
docker run -d --name mariadb \
--label 'dd.link.template=https://hub.docker.com/_/mariadb?tab=tags&name=${raw}' \
mariadb:10.6.4-jammyTag 10.6.4-jammy renders https://hub.docker.com/_/mariadb?tab=tags&name=10.6.4-jammy.
services:
mariadb:
image: mariadb:10.6.4
labels:
- dd.link.template=https://mariadb.com/kb/en/mariadb-$${major}$${minor}$${patch}-changelogFailure behavior
- Unknown placeholder: substituted with an empty string. The template still renders and drydock never rejects or logs it — a typo like
${majro}quietly produces a broken-looking URL instead of an error. - Invalid or non-
http(s)URL: drydock doesn't validate the rendered value at all — any string is accepted and stored oncontainer.link/result.link. The UI's release-link action only renders it as a clickable link when the rendered string starts withhttp://orhttps://(case-insensitive); anything else (a bare path, ajavascript:URL, malformed output from an unmatched transform) is silently dropped from the UI instead of shown broken.
Customize the name and the icon to display
You can customize the name & the icon of a container (displayed in the UI, in Home-Assistant...)
Icons must be prefixed with:
fab:orfab-for Fontawesome brand icons (fab:github,fab-mailchimp...)far:orfar-for Fontawesome regular icons (far:heart,far-house...)fas:orfas-for Fontawesome solid icons (fas:heart,fas-house...)hl:orhl-for Homarr Labs icons (hl:plex,hl-authelia...)mdi:ormdi-icons are auto-resolved to Dashboard Icons but are not recommended; preferhl:orfaprefixes insteadsh:orsh-for Selfh.st (sh:authentik,sh-authelia-light...) (only works for logo available aspng)si:orsi-for Simple icons (si:mysql,si-plex...)
services:
mariadb:
image: mariadb:10.6.4
labels:
- dd.display.name=Maria DB
- dd.display.icon=si:mariadbLabel a specific port
You can attach a custom label to one of a container's published ports. It's shown on the clickable port link in the container list and detail views instead of the raw hostPort->containerPort/protocol mapping. Keys are <port>=<label> pairs, comma-separated for multiple ports; a bare port number matches /tcp by default.
services:
traefik:
image: traefik:3
ports:
- "80:80"
- "8443:443"
labels:
- dd.port.label=80=Web UI,443=Admin ConsoleAssign different triggers to containers
You can assign different triggers and thresholds on a per container basis.
Example send a mail notification for all updates but auto-update only if minor or patch
services:
my_important_service:
image: my_important_service:1.0.0
labels:
- dd.notification.include=smtp.gmail
- dd.action.include=dockercompose.local:minordd.notification.include=smtp.gmailis a shorthand fordd.notification.include=smtp.gmail:alldd.action.include=update(ordd.action.exclude=update) targets all triggers namedupdate, for exampledocker.updateanddockercompose.update
Threshold values:
| Threshold | Behavior |
|---|---|
all | Trigger runs regardless of the nature of the change |
major | Trigger runs only for major, minor, or patch semver changes |
minor | Trigger runs only for minor or patch semver changes |
patch | Trigger runs only for patch semver changes |
digest | Trigger runs only on digest updates |
*-no-digest | Any threshold ending with -no-digest excludes digest updates for that threshold |
Container Runtime Details
Each monitored container exposes runtime details sourced from Docker inspect and the container summary. These are visible in the API response and the UI:
| Field | Type | Description |
|---|---|---|
details.ports | string[] | Published port mappings (e.g. 8080->80/tcp, 443/tcp) |
details.volumes | string[] | Volume and bind mounts (e.g. myvolume:/data, /host/path:/container/path:ro) |
details.env | { key, value }[] | Environment variables set on the container |
details.startedAt | string (ISO 8601) | When the container's current run started (State.StartedAt from Docker inspect). Drives the live "Up …" uptime display in the container list and detail views; absent for stopped containers. |
In the UI, each host-published port in details.ports renders as a clickable link (opened in a new tab). The target scheme is auto-detected from the container-side port — 443 and 8443 get https://, everything else gets http:// — and the host is the published HostIp when it's a real bindable address, falling back to the agent's configured host for agent-watched containers, or the browser's own hostname otherwise. Internal-only (unpublished) ports render as plain text. Use dd.port.label to give a port a friendly display name instead of the raw mapping.
Container Status Fields
The container model includes additional fields useful for understanding update state:
| Field | Type | Description |
|---|---|---|
result.noUpdateReason | string | When no update is available, explains why (e.g. tag filtering, no newer version found) |
updateDetectedAt | string (ISO 8601) | Drydock's own wall-clock timestamp recorded when it first stored a pending update for this container. This is not the image build date or the registry push date. The value is preserved across polls and across container recreation — if a container is redeployed (e.g. via a Portainer stack redeploy or docker compose down && up) with the same stable identity and the same update still pending, the original timestamp is carried forward. When no trusted registry publish date is available, the maturity gate uses updateDetectedAt as its fallback clock to measure how long an update has been known. |
maturityGatePendingSince | string (ISO 8601) | Set at the moment an update starts being withheld by the maturity gate (maturityMode: mature and the soak window not yet elapsed); absent otherwise. Preserved across polls and container recreation like updateDetectedAt. Cleared silently if the pending update disappears, and cleared the moment the gate opens, which is also what triggers the maturity-cleared notification. Internal bookkeeping — not something you set directly. |
image.tag.tagPrecision | 'specific' | 'floating' | Whether the container's tag is a mutable version alias (floating, e.g. latest, v3, 1.4) or a pinned release (specific, e.g. 1.4.5). Computed automatically; drives digest-watch auto-enable logic and the floating-tag caution badge. The Hide Pinned UI filter uses the separate shape-based tagPinned field instead — see Hide Pinned toggle. |
Next-run visibility
The Watchers and Agents views show when each watcher will next poll. The Next Run column displays a live relative countdown (e.g. "in 14m"). Hover over the countdown to see the absolute local timestamp (e.g. Apr 18, 14:32:07), making it easy to correlate the next poll with other scheduled work. The watcher API also exposes nextRunAt for programmatic consumers.
UI: floating-tag detection and Hide Pinned
Floating tag detection (tagPrecision)
drydock classifies each container's tag as specific (pinned release, e.g. 1.4.5) or floating (mutable alias, e.g. latest, v3, 16-alpine) using the tagPrecision classifier. This has two practical effects:
- Auto-digest watching — Floating tags on non-Docker Hub registries have digest watching enabled automatically so in-place rebuilds are detected even when the tag name does not change.
- Caution badge — When a container has a floating tag without digest watching active, the container detail view shows a caution indicator.
The tagPrecision field is computed from the tag name and any active dd.tag.transform. You do not need to configure it manually.
Tag and Version columns
The containers table has two distinct columns for version information:
- Tag (column key
version) — the image tag used for update detection (e.g.latest,1.4.5). This column was called "Version" in releases before v1.5.1; the column key is unchanged so saved column preferences keep working. - Version (column key
softwareVersion) — the application version surfaced fromimage.softwareVersion(theorg.opencontainers.image.versionOCI label, add.inspect.tag.pathextract, or other runtime metadata). Falls back to the image tag whensoftwareVersionis not available. Visible by default for new installs; added automatically to saved column lists on upgrade.
In v1.8.0-rc.1, digest checks also retain the remote candidate's OCI version label in the API's result.softwareVersion field when the selected platform's image config provides it. This optional field is separate from the installed image.softwareVersion shown in the Version column. Missing or unusable labels leave it absent. The label alone does not enable a gate. Release-count maturity uses this candidate version or bounded digest proof only when its policy is configured.
Version column display
The Tag column adapts based on tagPrecision:
- Floating-tag + digest-watch containers (e.g.
prom/prometheus:latest): shows the human-readable tag (not raw SHA). The digest transition is available in the cell tooltip and detail panels. - Digest-pinned containers (
image.tag.valuestarts withsha256:): shows thesha256:abc… → sha256:def…pair directly in the cell.
Hide Pinned toggle
The Hide Pinned filter in the Containers toolbar hides containers whose tag names a specific version (the shape-based tagPinned field, e.g. 1.4.5, 16-alpine, v3) — not containers whose tagPrecision is specific. Rolling aliases like latest and stable, and bare distro/variant tags like alpine, slim, and bookworm, are never considered pinned even though they contain no version number. This is useful for decluttering the list when you have infrastructure containers (databases, proxies) pinned to specific versions and want to focus on containers with available updates. The preference is persisted across sessions.
Docker Event Stream
The watcher monitors Docker events (container create, destroy, start, stop, etc.) in real time. If the event stream disconnects, Drydock automatically reconnects with exponential backoff starting at 1 second and capping at 30 seconds.
When using third-party socket proxies, periodic reconnects are expected: many proxies close idle long-lived HTTP connections after their configured timeout. In logs this appears as:
Docker event stream error (aborted); reconnect attempt #1 in 1000ms
followed by:
Listening to docker events
The first reconnect attempt is logged at info level since it is expected behavior (proxy timeout or network blip). If the first attempt fails and subsequent reconnects are needed, those are logged at warn level to flag a potential real problem.
Event gap during reconnect
Container lifecycle events (create, start, stop, destroy) that occur during the brief reconnect window are not captured until the next scheduled poll cycle. The reconnect window is typically ~1 second under normal conditions. In practice this is rarely noticeable because the next cron poll will pick up the current container state, but it means Drydock does not guarantee zero missed events across a reconnect.
Native socket proxy
The root cause of proxy-timeout reconnects is that Drydock cannot control idle timeout behavior on third-party proxies.
Reduce proxy-timeout reconnect churn
To reduce reconnect noise:
- Increase your proxy idle timeout (for example, linuxserver socket-proxy
TIMEOUT=86400s). - Prefer direct socket mount when your environment allows it.
- If your proxy supports endpoint-specific behavior, exempt Docker
/eventsstreams from idle timeout. - As a workaround, run a lightweight heartbeat container (e.g.
alpine:latestwithcommand: sh -c "sleep 300"andrestart: always) to keep the proxy connection path active.