Actions & Notifications
Configure update-action providers, notification providers, event rules, templates, and routing.
Action and notification providers are enabled using environment variables.
DD_ACTION_{{ trigger_type }}_{{ trigger_name }}_{{ trigger_configuration_item }}=XXX
DD_NOTIFICATION_{{ trigger_type }}_{{ trigger_name }}_{{ trigger_configuration_item }}=XXXRoute notifications to a group
On the v1.8.0-rc.1 candidate, set DD_NOTIFICATION_<TYPE>_<NAME>_GROUP to restrict one notification instance to one named group. For example, DD_NOTIFICATION_SLACK_PAYMENTS_GROUP=payments restricts the existing Slack instance named payments. Its destination and credentials are configured as usual. In drydock.yml, set the equivalent notification.slack.payments.group: payments field; environment values override the file.
The server resolves the container's group from dd.group, then com.docker.compose.project, then com.docker.stack.namespace. Names match exactly, including case, whitespace and punctuation. Empty or whitespace-only configured names are invalid. An empty higher-priority label does not fall through to a lower-priority label. Containers without a named group never match; the UI's ungrouped bucket is not a selectable routing identity. Browser grouping preferences, arbitrary label views and shared grouping URLs do not change notification routing.
This selector narrows existing delivery. Notification include/exclude labels, event rules, thresholds and agent affinity still apply. Controller-owned notification instances can match containers on different agents; an agent-owned instance cannot cross its existing agent boundary. Group-restricted instances do not receive agent-connectivity events because those events have no container group. Leave the setting absent on another instance to keep those notifications.
The filter covers update notifications, lifecycle/security events, batches and digests. Queued lifecycle batches, digests and batch retries recheck eligibility against the latest known container before sending, including replacements with a new Docker ID. Replacement matching uses stable container identity and only an unambiguous name within the same agent and watcher. If the container is no longer in the store, lifecycle batches and digests fall back to the admitted snapshot; ambiguous matches and entries that no longer qualify are discarded. Already accepted durable outbox deliveries retain their existing retry semantics. A security digest includes only eligible finding rows; its scanned-container count still describes the whole scan cycle.
Use separate notification instances for different groups or destinations. This setting is file/environment configuration, not a new live-editor field. Action providers reject GROUP; it grants no action permission and defines no inherited update policy. Omitting it preserves existing notification behavior.
Per-container routing overrides
On the v1.8.0-rc.1 candidate, dd.notification.include, dd.notification.exclude, dd.action.include, dd.action.exclude and dd.action.auto can each be overridden per container through the label override API, without editing the container's labels. An override replaces that label's list whole, and an empty list is an explicit "none". Routing resolves in this order for each field: the Drydock override, then the Docker label, then watcher or image set configuration.
An override changes which triggers a container reaches, nothing else. These limits still apply on top of it:
- A notification trigger with a group selector is checked against the container's group first.
dd.groupcannot be overridden, so a routing override can narrow or widen delivery only inside that group and can never route outside it. - The global update mode ceiling and each action trigger's
AUTOsetting still apply. AnAUTO=nonetrigger never runs automatically, whateverdd.action.autosays. - Agent affinity still applies. On a traditional agent, action include and auto overrides can only narrow and an exclude override can only add entries, because the agent re-checks its own labels. See Override labels with the API.
- Overrides are saved without sending a notification or running an action. Queued batches and digests recheck the current container before sending, so an override saved while one is buffered applies when it flushes.
Trigger references in an override are checked when you save: each must match a registered trigger of that field's category, and a reference that matches only the other category is rejected. When an action include, exclude or auto override blocks an update, the 409 message and the eligibility blocker name the Drydock override instead of the container label.
Edit notification delivery policy in the UI
This editor and the action policy editor below are new in v1.8.0-rc.1 builds and require a writable configuration file.
Open a notification provider in Triggers, then choose Edit notification policy. The editor fetches a fresh configuration snapshot only when you open it or explicitly reload it. It edits threshold, once, mode, securitymode, digestcron, and resolvenotifications, plus securitydigesttitle and securitydigestbody when the server provides them. The two boolean fields use Yes/No choices and are saved as booleans, including false. Older servers retain the six-field editor with template controls unavailable.
The threshold selector supports all, major, minor, patch, major-only, minor-only, digest, major-no-digest, minor-no-digest, patch-no-digest, major-only-no-digest, and minor-only-no-digest. Both mode selectors support simple, batch, digest, and batch+digest.
Each field shows whether it comes from the configuration file, environment, a reference, or a default. Environment-owned values, ${NAME} and _file references, agent-owned triggers, and provider-forced values such as MQTT's delivery mode cannot be changed here. MQTT does not support security digest templates, so its template fields are read-only. Missing files, unsupported servers, and denied requests are reported without offering an unsafe save. Credentials, destinations, other templates, execution settings, and action providers remain outside this editor. Reference values and credentials are not round-tripped through the form.
Security digest templates use a title input and multiline body. Case, whitespace, and newlines are preserved. Empty templates are invalid; use explicit removal to restore the built-in defaults used when rendering, rather than saving an empty string. Preparing a template does not change securitymode or enable digest delivery. Whole values such as ${NAME} or ${NAME:-fallback} use YAML environment interpolation and become read-only references in the editor after reload. Scan expressions such as ${scan.alertCount}, including at the beginning of a template, remain editable. Templates are public notification text: do not put secrets in them. This form does not add template syntax, preview execution, or database-backed template overrides.
Save sends only changed fields to PATCH /api/v1/config/editor/triggers, using the opaque revision from the session-only GET snapshot and the exact provider, instance, and agent identity. Cancel makes no changes. Remove file value / use default explicitly removes a YAML field; an empty text input is a different edit and is validated by the server. Saving policy does not run Test Trigger or send a notification.
A stale revision produces a conflict: your draft stays visible, Save is blocked, and Discard draft and reload is required before another attempt. The editor never retries with a newer revision automatically. Saved-file status, live reload status, errors, reconciliation, and any restart requirements are shown separately. A saved but unapplied result is not reported as success, and audit errors remain visible even when the change was applied. After a saved result, the surrounding trigger detail and matching list row refresh once; the editor preserves its draft and outcome until you explicitly reload or close it.
This editor does not change database-backed notification rules or the notification bell. Other configuration fields and per-container label overrides are not included in this slice.
Edit action policy in the UI
Open an existing Docker, Docker Compose, Portainer, or Command provider in Triggers, then choose Edit action policy. The form loads GET /api/v1/config/editor/actions only on Edit or explicit reload. It shows auto, order, and concurrency, together with source ownership and the server's effective values. Credentials, commands and provider execution settings stay outside the form.
Automatic dispatch uses the four named modes below. Existing boolean and mixed-case values are normalized only for display and comparison; untouched file values are not rewritten. Order accepts negative and fractional numbers. Concurrency requires a positive whole number. Blank numeric inputs are invalid, not zero or removal. Remove file override / inherit settings explicitly removes only that file leaf. The form does not guess hidden inherited values or replace them with 1.
Save sends only changed leaves with the snapshot revision and exact original-case paths. Cancel does not write. Environment-owned, referenced, agent-owned and ambiguous fields remain read-only. Conflicts retain the draft and require explicit reload; uncertain saves are never automatically retried. Saved-file and live-application outcomes remain separate, including reload and audit errors. A saved result refreshes only the matching surrounding detail/list row while retaining editor feedback. Saving never invokes Test Trigger, executes an action or sends a notification.
The fields use the providers' existing validation and runtime defaults:
auto: boolean or case-insensitiveall,oninclude,onauto, ornone. Booleans normalize toall/none; an absent action value defaults tooninclude. Changing it does not bypass global manual mode or container eligibility.order: a number, including negative and fractional values, with the existing default of100. Existing provider/group defaults and same-name sharing still apply. Saving or removing an order can also change the effective order of other same-name triggers, including notification providers that inherit it; the editor does not change that precedence.concurrency: a positive integer override. Removing it inheritsDD_UPDATE_CONCURRENCY, then1when the global value is absent. Compose concurrency remains across independent projects, not within one compose file.
An administrator can send PATCH /api/v1/config/editor/actions with the snapshot revision and a changes array. Each change uses the exact original-case path returned by the snapshot, operation: "set" with a scalar value, or operation: "remove" without a value. Send only changed fields. Removal is distinct from an empty string, which still goes through normal provider validation. No arbitrary paths or instance creation/deletion are supported.
Environment-owned values, references, agent-owned actions, ambiguous YAML identities, and unavailable files are read-only. Credentials, command text, destinations, sockets, TLS, templates, provider execution options, global settings, labels, and database-backed policy are excluded. Reference-derived values are omitted, including inherited order or concurrency; a local override can remain editable without revealing the inherited value. Untouched YAML nodes, comments, interpolation, and secret-file references stay on the server.
The revision protects against stale saves, including an external edit during validation. A 409 requires an explicit fresh snapshot and a new user decision, not an automatic retry. Invalid requests or provider validation return 400 without writing. A successful atomic save returns saved: true; inspect applied, errors, restartRequired, and reload separately. A saved file whose reload failed is not rolled back or reported as applied. Audit errors can be present even when the live change was applied.
Saving uses the same serialized write queue as watcher, notification, and whole-section configuration writes. Live reload waits for active update lifecycles before replacing changed action instances, so later dispatch uses the new settings. Validation, saving, and reloading do not dispatch an update, execute a command, or send a test notification.
Environment variable prefixes
Two prefixes are supported for trigger configuration (the legacy third was removed in v1.7.0):
| Prefix | Status | Example |
|---|---|---|
DD_ACTION_* | Current | DD_ACTION_DOCKER_MYSERVER_PRUNE=true |
DD_NOTIFICATION_* | Current | DD_NOTIFICATION_SMTP_GMAIL_HOST=smtp.gmail.com |
DD_TRIGGER_* | Removed in v1.7.0 — accepted in v1.6 with an error-level warning for every detected input; any DD_TRIGGER_* variable now fails startup | DD_TRIGGER_SLACK_MYSLACK_URL=https://... |
Use DD_ACTION_* for update-action providers (Docker, Docker Compose, Portainer, Command) and DD_NOTIFICATION_* for messaging providers (Slack, SMTP, Discord, etc.). The legacy DD_TRIGGER_* prefix no longer works as of v1.7.0 — drydock fails to start and lists every detected variable next to its exact replacement.
When the same trigger property is set under multiple prefixes, the merge priority is DD_NOTIFICATION_* > DD_ACTION_* (highest wins).
DD_TRIGGER_* to DD_ACTION_* automatically: node dist/index.js config migrate --source trigger. Use --dry-run to preview changes before applying. Note that the CLI always emits DD_ACTION_* for every trigger type — if you use messaging providers (Slack, SMTP, Discord, ntfy, and others), you may prefer to rename those to DD_NOTIFICATION_* manually to match the canonical prefix for your provider category.Migrating trigger prefixes
The following example shows a typical before/after migration for a Docker + Slack setup.
Before (deprecated DD_TRIGGER_* form):
# .env — legacy form (deprecated in v1.5.0, removed in v1.7.0)
DD_TRIGGER_DOCKER_LOCAL_PRUNE=true
DD_TRIGGER_SLACK_MYSLACK_URL=https://hooks.slack.com/services/T000/B000/xxxx
DD_TRIGGER_SMTP_GMAIL_HOST=smtp.gmail.com
DD_TRIGGER_SMTP_GMAIL_PORT=465Run the migration (add --dry-run to preview without writing):
node dist/index.js config migrate --source triggerAfter (canonical prefixes):
# .env — canonical form
# Update-action triggers use DD_ACTION_*
DD_ACTION_DOCKER_LOCAL_PRUNE=true
# Messaging/notification triggers use DD_NOTIFICATION_*
DD_NOTIFICATION_SLACK_MYSLACK_URL=https://hooks.slack.com/services/T000/B000/xxxx
DD_NOTIFICATION_SMTP_GMAIL_HOST=smtp.gmail.com
DD_NOTIFICATION_SMTP_GMAIL_PORT=465DD_ACTION_* for all trigger types. For messaging providers (Slack, SMTP, Discord, ntfy, etc.), rename those outputs to the canonical DD_NOTIFICATION_* category when practical. Both current prefixes resolve today, but category-correct naming keeps configuration aligned with the UI, API, and documentation.Available triggers
Update actions:
Portainer is also an update-action provider and uses the DD_ACTION_PORTAINER_* prefix.
- Docker -- Update containers via the Docker Engine API
- Docker Compose -- Update containers managed by Docker Compose
- Portainer -- Redeploy local Portainer-managed Compose stacks
- Command -- Execute a shell command
Messaging & notifications:
- Apprise -- Multi-provider notification relay
- Discord -- Discord webhook
- Google Chat -- Google Chat webhook
- Gotify -- Gotify push notification
- HTTP -- Generic HTTP request
- IFTTT -- IFTTT webhook
- Kafka -- Apache Kafka topic
- Matrix -- Matrix room message
- Mattermost -- Mattermost webhook
- MQTT -- MQTT topic
- ntfy -- ntfy push notification
- Pushover -- Pushover notification
- Rocket.Chat -- Rocket.Chat webhook
- Slack -- Slack webhook
- SMTP -- Email notification
- Teams -- Microsoft Teams webhook
- Telegram -- Telegram bot message
Common trigger configuration
All implemented triggers, in addition to their specific configuration, also support the following common configuration variables. The table uses DD_NOTIFICATION_{trigger_type}_{trigger_name}_* for notification triggers and DD_ACTION_{trigger_type}_{trigger_name}_* for action triggers (see Environment variable prefixes above). For brevity the rows below show the DD_NOTIFICATION_* form; replace with DD_ACTION_* for Docker, Docker Compose, Portainer, and Command triggers.
Manual updates by default
On the v1.8.0-rc.1 candidate, Docker, Docker Compose and Portainer actions can keep every unlabeled container manually updatable while requiring a label for automatic updates:
DD_ACTION_DOCKER_DATAVAULT_AUTO=onauto
DD_ACTION_DOCKER_DATAVAULT_MANUAL=allSet dd.action.auto=datavault only on the containers that should update automatically. Leave the others unlabeled. The global update mode must be auto; global or group manual remains a ceiling that labels cannot lift. Configure the action on each instance that watches the containers, and upgrade traditional agents to v1.8 before configuring this option there.
MANUAL accepts all or oninclude. When omitted, or set to oninclude, the existing closed default under AUTO=onauto and AUTO=oninclude is unchanged. With MANUAL=all, an explicit dd.action.include list still restricts access, including an explicit empty override, and a matching container or group exclusion still blocks the action. Thresholds and agent matching also still apply. AUTO=all and AUTO=none keep their existing behavior regardless of this setting. Notification and Command triggers do not accept MANUAL.
The same setting works with AUTO=oninclude: unlabeled containers stay manual, and dd.action.include opts selected containers into automatic updates. Under AUTO=onauto, dd.action.include alone grants manual access and dd.action.auto grants automatic access.
Variables
| Env var | Required | Description | Supported values | Default value when missing |
|---|---|---|---|---|
DD_ACTION_{trigger_type}_{trigger_name}_MANUAL | ⚪ | Manual access baseline for Docker, Docker Compose and Portainer actions on v1.8. Under AUTO=onauto or AUTO=oninclude, all grants manual access when no explicit include list exists. It does not grant automatic execution or bypass an exclusion. | all, oninclude | Existing access rules (oninclude) |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_AUTO | ⚪ | Controls automatic execution for update-available events. true (or legacy all) — auto-execute for all watched containers, ignoring dd.action.include/dd.action.auto. oninclude — auto-execute only for containers with an explicit dd.action.include or dd.notification.include label (manual access follows the same label). onauto (action triggers only) — split manual and automatic access: dd.action.include still grants manual access, but automatic access additionally requires a dd.action.auto label match. false (or legacy none) — disable automatic update-available notifications/actions for this trigger, but still open manual access to every watched container, ignoring dd.action.include. Note: AUTO=false does not suppress lifecycle notifications (update-applied, update-failed, security-alert, agent-disconnect, agent-reconnect). | true, false, oninclude, onauto, all, none | oninclude for action triggers (docker, dockercompose, portainer, command); true for notification triggers |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_BATCHTITLE | ⚪ | The template to use to render the title of the notification (batch mode) | String template with ${containers} context | ${containers.length} updates available |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_MODE | ⚪ | Trigger for each container update, trigger once with all available updates as a list, or accumulate updates and send on a schedule | simple, batch, digest, batch+digest | simple |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_DIGESTCRON | ⚪ | Cron schedule for digest mode flush (only used when MODE=digest or MODE=batch+digest) | Cron expression | 0 8 * * * |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_SECURITYMODE | ⚪ | How security-alert events are delivered: one notification per container (simple), all alerts in a cycle batched on completion (batch), or one digest per scan cycle (digest) | simple, batch, digest, batch+digest | simple |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_SECURITYDIGESTTITLE | ⚪ | The template to use to render the title of the security digest (only used when SECURITYMODE=digest or SECURITYMODE=batch+digest) | String template with scan.* vars | Security scan complete: ${scan.alertCount} ${scan.containerNoun} with findings |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_SECURITYDIGESTBODY | ⚪ | The template to use to render the body of the security digest | String template with scan.* vars | Critical/high severity summary plus scan window |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_ONCE | ⚪ | Run trigger once (do not repeat previous results) | true, false | true |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_ORDER | ⚪ | Trigger execution order (lower runs first) | Number | 100 |
DD_EVENT_HANDLER_TIMEOUT_MS | ⚪ | Per-handler execution cap in milliseconds. If a single notification handler (e.g. an SMTP transport stalling on retry) runs longer than this value, it is abandoned and the emit chain continues. Prevents one slow provider from stalling the entire update lifecycle. Applies globally — not per-trigger-name. If the value is absent, zero, or non-numeric it falls back to the default silently. | Positive integer (milliseconds) | 30000 (30 s) |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_SIMPLEBODY | ⚪ | The template to use to render the body of the notification | JS string template with vars container | Agent-prefixed update body with watcher suffix; digest updates say the current tag has a newer image, tag/semver updates show local and remote values, and result links are appended when present |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_SIMPLETITLE | ⚪ | The template to use to render the title of the notification (simple mode) | JS string template with vars ${container} | Agent-prefixed update title with watcher suffix; digest updates use New image available for container ... (tag currentTag), other updates use New {kind} found for container ... |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_THRESHOLD | ⚪ | The threshold to reach to run the trigger | all, major, major-only, minor, minor-only, patch, digest, and *-no-digest variants (major-no-digest, major-only-no-digest, minor-no-digest, minor-only-no-digest, patch-no-digest) | all |
DD_NOTIFICATION_{trigger_type}_{trigger_name}_RESOLVENOTIFICATIONS | ⚪ | Auto-dismiss the notification after the container is successfully updated | true, false | false |
AUTO also sets the baseline access default, not just automatic execution
all and none don't only decide whether an update fires automatically — they also decide whether dd.action.include is consulted for manual access at all. AUTO=none reads like "off," but it means "no automatic execution": every watched container still gets manual access for that trigger, regardless of dd.action.include. AUTO=all is the same in reverse — every container gets manual and automatic access, ignoring dd.action.include. Only oninclude and onauto make dd.action.include (and, under onauto, dd.action.auto) actually gate access. See the labels reference for the full breakdown.
Threshold reference
| Threshold | Fires on |
|---|---|
all | Any change (default) |
major | Any semver change, including prerelease (the major threshold does not actually cap the change magnitude) |
major-only | Major only |
minor | Any semver change that is not major (minor, patch, prerelease) |
minor-only | Minor only |
patch | Any semver change that is not major or minor (patch, prerelease) |
digest | Digest changes only |
The semver thresholds can be suffixed with -no-digest to exclude digest-only updates. Supported no-digest variants are major-no-digest, major-only-no-digest, minor-no-digest, minor-only-no-digest, and patch-no-digest. Updates with an unknown update kind (e.g. created-date-only changes) are passed through when threshold=all, but filtered out for all other threshold values.
Notes
RESOLVENOTIFICATIONSis currently implemented for Gotify (auto-deletes the notification message after a successful update). Other providers can be extended to support it.- Some messaging triggers (Slack, Telegram, Teams, Matrix, Mattermost, Rocket.Chat, Google Chat) also support
DISABLETITLE(defaultfalse) to send only the body without a title line. - You can set
DD_NOTIFICATION_{trigger_type}_THRESHOLD,_ONCE,_MODE, or_ORDERto define a default for all triggers of the same type, e.g.DD_NOTIFICATION_NTFY_THRESHOLD=minor(orDD_ACTION_{trigger_type}_...for action triggers). The same four keys,THRESHOLD,ONCE,MODE, andORDER, are the ones this type-level default mechanism supports. - Triggers are executed by ascending
ORDER; when two triggers have the sameORDER, they are sorted by trigger id. - Triggers sharing the same trigger name (e.g.
docker.updateanddiscord.update) can shareTHRESHOLD,ONCE,MODE, andORDER; for each of those keys, if exactly one value is defined among them, that value is used for the others unless they override it explicitly. - Setting
ONCE=falsewithMODE=batchgives a report with all pending updates on every run. DIGESTCRONis validated withcron.validate()at registration time. An invalid cron expression causes a startup error with a descriptive message — the trigger will not be registered.MODE=digestaccumulates update events in an in-memory buffer and flushes them as a single batch notification on theDIGESTCRONschedule. If the same container has multiple updates within the window, only the latest is included. Containers that are updated before the digest fires are automatically evicted from the buffer. Useful for daily email digests:DD_NOTIFICATION_SMTP_GMAIL_MODE=digest+DD_NOTIFICATION_SMTP_GMAIL_DIGESTCRON=0 8 * * *.MODE=batch+digestenables both modes simultaneously — immediate batch emails on each scan cycle plus a scheduled digest summary. RequiresDIGESTCRONto be set.- Note: Digest and batch modes still depend on the
update-availablenotification rule being enabled (see below). If the trigger is excluded fromupdate-availablerouting, no events reach the buffer. SECURITYMODEgovernssecurity-alertdelivery independently ofMODE. A trigger may legitimately wantMODE=batchfor update notifications andSECURITYMODE=digestfor security findings. Digest and batch+digest modes buffer alerts per-scan-cycle (keyed bycycleId) and flush one summary per cycle on completion. Cycles with zero findings never emit. See the Security Hardening Guide for an end-to-end example.SECURITYDIGESTTITLEandSECURITYDIGESTBODYuse the same sandboxed${...}template renderer as other trigger templates. They substitutescan.*variables and are not evaluated as JavaScript.- Scheduled scans do not emit
security-alertevents by default. SetDD_SECURITY_SCAN_NOTIFICATIONS=trueto opt in. On-demand scans (UI button,POST /api/v1/containers/:id/scan,POST /api/v1/containers/scan-all) always emit. - Notification deduplication (
ONCE=true) is now persisted in thenotifications_historystore collection, soonce=truededup survives restarts. Previously the dedup state was lost on container restart, causing previously-suppressed notifications to re-fire. - Digest and batch-retry buffers enforce a 5,000-entry cap and a 7-day retention limit. Entries exceeding the cap or age are evicted before the next digest flush.
- When release notes bodies are truncated to fit within the notification body limit, the truncated body is marked with a trailing
…ellipsis so recipients know the notes continue at the source URL.
Notification Rules
Notification rules control which events fire which triggers. Each rule corresponds to an event type and has an enabled flag and an optional list of trigger IDs.
| Rule ID | Default enabled | Description |
|---|---|---|
update-available | Yes | A container has a newer version available |
update-applied | Yes | A container was successfully updated |
update-failed | Yes | An update failed or was rolled back |
security-alert | Yes | Critical/high vulnerability detected during pre-update scan |
agent-disconnect | No | A remote agent lost its connection |
agent-reconnect | No | A remote agent reconnected after losing connection |
container-unhealthy | No | A container's Docker health check entered the unhealthy state |
maturity-cleared | Yes | An update previously withheld by the maturity policy (maturityMode: mature) has become applicable |
update-pending-approval | No | A manual-mode update entered the approval queue |
The in-app bell has audit-backed controls for update-available, update-applied, update-failed, security-alert, agent-disconnect, container-unhealthy, and maturity-cleared. agent-reconnect and update-pending-approval remain delivery rules but do not expose a bell toggle because neither action is part of the bell's audit query. Notification-delivery failures are always included. Agent status changes refresh disconnect entries; health-only container changes propagate across local and agent paths, and a replayable health-transition event is ordered after audit processing so the bell refetch sees any newly inserted unhealthy row.
Dispatch behavior:
update-available-- when its trigger list is empty, all notification triggers fire (legacy default). Once you assign specific triggers, only those fire.update-applied,update-failed,security-alert-- when their trigger list is empty, all notification triggers fire (same permissive default asupdate-available). Once you assign specific triggers, only those fire.agent-disconnect,agent-reconnect-- disabled by default. Enable them and assign triggers to receive disconnect/reconnect alerts. When their trigger list is empty, all notification triggers fire. These events always fire regardless of the threshold setting.container-unhealthy-- disabled by default. Enable it and assign triggers to receive container health alerts. When its trigger list is empty, all notification triggers fire. This event always fires regardless of the threshold setting.maturity-cleared-- enabled by default. When its trigger list is empty, all notification triggers fire. Unlike the events above, it respectsTHRESHOLDandAUTOthe same wayupdate-availabledoes -- a trigger that would never have announced an update also won't announce its maturity, andAUTO=falsedisables it too.update-pending-approval-- disabled by default. The rule exists and can be enabled and assigned triggers today, but no event dispatches to it yet in this release; treat it as reserved for an upcoming one.
Rules are managed via GET /api/v1/notifications and PATCH /api/v1/notifications/:id, or through the Notifications view in the UI. The rule detail panel keeps three related controls together:
- Delivery routing —
enabledandtriggersdecide whether the event is delivered and which configured notification providers receive it. - In-app bell —
bellEnabledincludes or excludes an audit-backed event category from the header bell. Forupdate-available,bellThresholdcan show all updates, major only, minor-and-up, or patch-and-up. Rules without bell audit coverage hide these controls. - Template overrides — choose a configured notification trigger and override its
simpleTitle,simpleBody, and/orbatchTitlefor this rule. Empty fields inherit the trigger's configured/default template. Preview renders the draft with representative event data before saving.
Overrides are keyed by canonical trigger ID, so the same event can use different copy for Slack, SMTP, ntfy, or any other configured notification provider. They use the same sandboxed ${...} syntax and variables documented in Template variable reference. The preview API is POST /api/v1/notifications/:id/preview; see the Notification rules API for its request shape.
Event Types
Beyond the classic update-available event, triggers can fire on additional events:
update-applied-- fires after a container is successfully updated by a Docker or Docker Compose trigger. Useful for audit or confirmation notifications.update-failed-- fires when an update fails or is rolled back. Carries the container name and error message.security-alert-- fires when the Update Bouncer detects critical or high vulnerabilities during a pre-update scan. Includes a vulnerability summary.agent-disconnect-- fires when a remote agent loses its connection. This event skips threshold checks (always fires).agent-reconnect-- fires when a remote agent reconnects after losing connection. Only fires on true reconnects, not the initial startup connection. This event skips threshold checks (always fires).container-unhealthy-- fires when a container's Docker health check transitions into theunhealthystate. Does not fire for containers with noHEALTHCHECKconfigured, and does not fire again while the container remains continuously unhealthy -- except when the container restarts (new boot) while unhealthy, which counts as a new episode and fires again. This event skips threshold checks (always fires).maturity-cleared-- fires when an update previously withheld by the maturity gate (maturityMode: mature) becomes applicable -- the soak window elapses, an override lifts the gate, or the policy changes. Default title:Update ready for container <name>. Default body:Container <name> can now be updated to <tag or digest>: the maturity period has passed, with a trailing(N day minimum)clause when the resolved policy'smaturityMinAgeDaysis known. Default batch title:<N> updates ready to apply. Detected both at the container's next watch-cycle scan and, independently and typically sooner, by the maturity gate sweep. Deduplicated against the genericupdate-availablenotification for the same result, so the same update is never announced twice. Action triggers are unaffected -- Docker, Docker Compose, and Command triggers keep applying the update on their normal watch/scan cadence; this event only affects notification delivery.
Maturity Gate Sweep
A background sweep independently re-checks containers currently withheld by the maturity gate and fires the maturity-cleared notification (see Event Types above) as soon as each one's soak window elapses, instead of waiting for the watcher's next scheduled scan.
| Env var | Required | Description | Supported values | Default value when missing |
|---|---|---|---|---|
DD_MATURITY_SWEEP_CRON | ⚪ | Cron schedule for the maturity gate sweep. Global -- not per-watcher or per-trigger. Set to an empty string to disable the sweep. | Valid CRON expression | */5 * * * * (every 5 minutes) |
The sweep only inspects containers that already have a pending update withheld by the gate -- it performs no registry lookups and adds no scan load. Disabling it does not disable the maturity-cleared notification itself: the check that runs on every watch-cycle scan still detects the transition, just bounded by the watcher's own CRON interval (default every 6 hours) instead of the sweep's.
Notification Outbox
Drydock uses a durable notification outbox to persist and retry failed notification deliveries. When a trigger call fails (network error, provider down, etc.), the delivery intent is saved and retried with exponential backoff and jitter rather than being silently dropped.
Default behavior:
- Max attempts: 5. After 5 failed attempts the entry moves to dead-letter status.
- Backoff: 30 s base, doubles per attempt, capped at 5 minutes. Up to 5 s jitter per retry.
- TTL: 30 days. Delivered and dead-letter entries older than 30 days are purged automatically.
API:
| Endpoint | Description |
|---|---|
GET /api/v1/notifications/outbox | List outbox entries (filter by status=pending|delivered|dead-letter) |
POST /api/v1/notifications/outbox/:id/retry | Manually requeue a dead-letter entry |
DELETE /api/v1/notifications/outbox/:id | Discard an outbox entry |
UI: The Notification Outbox page (Settings → Notification Outbox, route /notifications/outbox) shows tabs for Dead-letter, Pending, and Delivered entries with Retry and Discard actions.
Diagnosing a missed notification
If a container has an update but the expected notification never arrives, turn on debug logging and read the fields drydock attaches to each cause rather than guessing -- a trigger skip, a detection failure, a provider error, or an outbox delivery state each carry different fields.
Turn on debug logging
Set DD_LOG_LEVEL=debug (see Logs). Every component logs through a child logger bound with a component field, so once debug logging is on you can narrow to the trigger you're investigating:
- A trigger's own logger is
trigger.<type>.<name>-- for exampleDD_NOTIFICATION_SLACK_OPS_URL=...logs astrigger.slack.ops. - Per-container log lines from a trigger add a
containerfield carrying the container's full name. Not every skip reason below goes through that per-container child logger, though -- some embed the container name directly in the message text instead (noted in the table). - A watcher's own registry-check failures log as
watcher.docker.<name>, also with acontainerfield -- worth checking first if the update was never detected at all, not just never notified.
With DD_LOG_FORMAT=json, filter with your log aggregator or grep, e.g. grep '"component":"trigger.slack.ops"'. With the default text format, use the Configuration > Logs page in the UI and its Component filter dropdown (see Logs).
Causes
Not every row below is a trigger deciding to skip. Registry-check failures are the watcher detecting nothing to report; outbox rows are what happens to a delivery after a trigger already tried and failed.
| Kind | Cause | Log line to look for | Level | Where the container/trigger name is |
|---|---|---|---|---|
| Trigger skip | Below the configured THRESHOLD | Threshold not reached => ignore (threshold=..., updateKind=..., semverDiff=...) | debug | container field |
| Trigger skip | ONCE=true and this exact result was already sent | Skipping update-available notification for <container> (once=..., updateAvailable=..., alreadyNotified=...) | debug | embedded in the message |
| Trigger skip | Excluded by a dd.notification.include/dd.notification.exclude label (or dd.action.* for action triggers), an agent mismatch, or the action-policy resolver | Trigger conditions not met => ignore (category=..., triggerInclude=..., triggerExclude=..., included=..., excluded=...) | debug | container field |
| Trigger skip | The update-available notification rule is disabled, or this trigger isn't in that rule's trigger allow-list | Skipping update-available notification for <container> (rule-disabled) or (excluded-from-allow-list) | debug | embedded in the message |
| Detection failure | The registry check itself failed, so no update was ever detected | Error when processing (<message>) | warn | container field; component is watcher.docker.<name> |
| Provider error | The update-available send itself threw (network error, bad credentials) or a ${...} template failed to render | Error (<message>), or Suppressed repeated error (<message>) once the same error already logged recently | warn (debug when suppressed) | container field |
| Provider error | A lifecycle event (update-applied, update-failed, security-alert, ...) failed delivery on the trigger's normal, non-batched dispatch path | Error handling <ruleId> event (<message>) | warn | not present -- neither a field nor embedded in the message; component names only the trigger. The failed delivery is then enqueued in the outbox (below) for retry. |
| Provider error | The same kind of lifecycle event failed delivery through a batched triggerBatch() call (trigger configured for batch mode) | Error handling <ruleId> event (<message>) | warn | not present, same as above. Not enqueued in the outbox -- a batched failure is not retried. |
| Outbox delivery state | Outbox retry backoff | Outbox <id> (event=<eventName> trigger=<triggerId>) attempt <n> failed: <message>; retry at <timestamp> | debug | embedded in the message |
| Outbox delivery state | Outbox dead-letter after 5 attempts | Outbox <id> (event=<eventName> trigger=<triggerId>) → dead-letter after <n> attempts: <message> | warn | embedded in the message |
The message text above is a stable log-line prefix, not a documented schema -- match on the leading words, not the whole line.
Worked example
A container named nginx has an update available, but the slack.ops notification trigger never fires. With DD_LOG_LEVEL=debug and DD_LOG_FORMAT=json:
{"level":20,"time":1732000000000,"component":"trigger.slack.ops","container":"nginx","msg":"Trigger conditions not met => ignore (category=notification, triggerInclude=<none>, triggerExclude=nginx, included=true, excluded=true)"}component names the trigger and container names the container; the message's triggerExclude=nginx shows a dd.notification.exclude label on the container whose regex also matches its own name. The fix is to narrow that label so it only excludes what was intended.
See also
- Notification Outbox above for retry/backoff and dead-letter behavior.
- Notification Rules above for the allow-list and the
rule-disabled/excluded-from-allow-listreasons. - Logs for
DD_LOG_LEVEL,DD_LOG_FORMAT, and the UI log viewer.
Template Variable Reference
Template strings use ${expression} placeholders. Drydock provides two sets of variables depending on the trigger mode.
Simple mode variables
| Variable | Description | Example value |
|---|---|---|
container | The full container object | (object) |
container.id | Container ID | sha256:abc123... |
container.name | Container name | my-app |
container.displayName | Display name (from dd.display.name label, defaults to name) | My App |
container.displayIcon | Display icon (from dd.display.icon label) | mdi:docker |
container.status | Container runtime status | running |
container.watcher | Watcher name | local |
container.agent | Agent name (when using distributed agents) | remote-1 |
container.notificationAgentPrefix | Server identification prefix: [agentName] + space for agent containers, [controllerName] + space for controller containers when agents exist, empty for single-server setups | [prod-server] |
container.notificationServerName | Server identity — agent name for agent containers, controller name (from DD_SERVER_NAME, otherwise the detected daemon host name when available, then the process hostname) for controller containers. Always resolved regardless of setup | prod-server |
container.image.name | Image name | library/nginx |
container.image.registry.name | Registry provider name | hub |
container.image.registry.url | Registry URL | https://registry-1.docker.io |
container.image.tag.value | Current image tag | 1.24.0 |
container.image.tag.semver | Whether the tag is valid semver | true |
container.image.digest.value | Current image digest | sha256:abc123... |
container.image.digest.watch | Whether digest watching is enabled | true |
container.image.architecture | Image architecture | amd64 |
container.image.os | Image OS | linux |
container.image.created | Image creation date | 2024-01-15T10:30:00Z |
container.updateKind.kind | Update type: tag, digest, or unknown | tag |
container.updateKind.localValue | Current version (tag or digest) | 1.24.0 |
container.updateKind.remoteValue | New version (tag or digest) | 1.25.0 |
container.updateKind.semverDiff | Semver diff level: major, minor, patch, prerelease, or unknown | minor |
container.result.tag | New tag value from the registry | 1.25.0 |
container.result.digest | New digest value from the registry | sha256:def456... |
container.result.created | Creation date of the new image | 2024-02-01T08:00:00Z |
container.result.link | Link to the release (when a link template is configured) | https://github.com/org/repo/releases/tag/v1.25.0 |
releaseNotes | Structured release notes for the available update target, when available | { title, body, url, publishedAt, provider } |
currentReleaseNotes | Structured release notes for the currently running tag, when available | { title, body, url, publishedAt, provider } |
container.error.message | Error message (when an error occurred) | 401 Unauthorized |
currentTag | Current image tag (top-level shortcut for container.image.tag.value) | 1.24.0 |
suggestedTag | Suggested new tag from the registry (top-level shortcut for container.result.tag) | 1.25.0 |
isDigestUpdate | Whether this update is digest-only rather than a tag change | false |
event.kind | Event kind for a non-baseline notification: update-failed, security-alert, container-unhealthy, maturity-cleared, agent-disconnect, or agent-reconnect; unset for an ordinary update | security-alert |
event.error | Error message (event.kind = update-failed) | Pull failed: 401 Unauthorized |
event.details, event.status, event.blockingCount | Alert details, status, and blocking-finding count (event.kind = security-alert) | 12 |
event.previousHealth | Health status before the container went unhealthy (event.kind = container-unhealthy) | healthy |
event.minAgeDays, event.pendingSince | Configured maturity window and when the gate started waiting (event.kind = maturity-cleared) | 7 |
event.agentName, event.reason | Agent name and disconnect reason (event.kind = agent-disconnect / agent-reconnect) | remote-1 |
Batch mode variables
| Variable | Description | Example value |
|---|---|---|
containers | Array of container objects (each with the same properties as above) | (array) |
containers.length | Number of containers in the batch | 3 |
Expression syntax
Inside ${...} you can use:
- Property paths --
container.updateKind.kind - Method calls --
container.name.substring(0, 15)(see allowed methods below) - Ternary --
container.result.link ? container.result.link : "no link" - Logical AND --
container.result && container.result.link - String concatenation --
"Version: " + container.updateKind.remoteValue - String and number literals --
"hello",42
Allowed string methods: substring, slice, toLowerCase, toUpperCase, trim, trimStart, trimEnd, replace, split, indexOf, lastIndexOf, startsWith, endsWith, includes, charAt, padStart, padEnd, repeat, toString.
Examples
services:
drydock:
image: codeswhat/drydock
...
environment:
- DD_NOTIFICATION_SMTP_GMAIL_SIMPLETITLE=Container $${container.name} can be updated
- DD_NOTIFICATION_SMTP_GMAIL_SIMPLEBODY=Container $${container.name} can be updated from $${container.updateKind.localValue.substring(0, 15)} to $${container.updateKind.remoteValue.substring(0, 15)}