Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Notifications

The notify subsystem alerts operators on lifecycle events within the ACME server, and web-admin operators on security events on their own account.

Supported events

EventFired when
profile_mountedA profile is initialized at startup.
account_createdA client registers a new account.
account_deactivatedAn account is deactivated.
certificate_issuedAn order is finalized and a certificate is minted.
certificate_revokedA certificate is revoked, via the ACME API or the admin CLI.
challenge_failedA domain-control validation attempt fails.
certificates_expiringThe periodic expiry digest, one per profile.
admin_sign_inA web-admin sign-in from an unfamiliar address, a refused second factor after a correct password, or a lockout.
admin_credential_changedA web-admin operator’s password or second factor changed.

These nine names are the only valid values wherever a backend’s events list is configured. An unrecognised name is a startup error, not a silently ignored entry.

certificates_expiring is the one that is not a thing that just happened. Every other event describes a single subject at the moment it changed; this one is a digest sent on a schedule, listing the certificates on one profile that expire inside a configured window. It sends nothing until notify.expiry.lead_days is set, so leaving it in an events list costs nothing.

admin_sign_in and admin_credential_changed are the web-admin operator security events (ASVS V6.3.5 / V6.3.7). They fire only on the process-wide [admin.notify] dispatcher — never on a profile’s [notify] — so listing either in a per-profile backend’s events costs nothing. Their email goes to the affected operator’s own address rather than to notify.email.to.

Backends

  • Email — SMTP, via lettre.
  • Webhook — any HTTP endpoint, with the URL, method, headers and body all configured. This is how Slack, Mattermost, Teams, Telegram and Matrix are reached: they differ in those four values and nothing else, so each is a configuration entry rather than a backend of its own.
  • Custom Script — shell out to a local script, for a channel that is not an HTTP request at all.

Email and webhook render their messages with MiniJinja templates you can override; see Customizing Templates.

Configuration

[notify]
# Which backends are active. Empty (the default) means no notifications at all.
enabled = ["email", "webhook"]

# Which [notify.webhook.<name>] entries to POST to, when "webhook" is listed
# above.
webhook_enabled = ["slack"]

# Which [notify.custom.<name>] entries to run, when "custom" is listed above.
custom_enabled = []

# Optional directory of template overrides, checked per template file before
# falling back to the compiled-in default.
template_dir = "/etc/acme-proxy/templates"

Reference

enabled (Array) — Default: [] | Env: ACME_PROXY_NOTIFY__ENABLED

Active backends: any of email, webhook, custom. Empty means the subsystem is off. "mattermost" was removed in favour of webhook and is refused by name.

webhook_enabled (Array) — Default: [] | Env: ACME_PROXY_NOTIFY__WEBHOOK_ENABLED

Which entries under [notify.webhook.<name>] to POST to, and in what order. Listing "webhook" in enabled while leaving this empty is a startup error, as is naming an entry that has no table.

custom_enabled (Array) — Default: [] | Env: ACME_PROXY_NOTIFY__CUSTOM_ENABLED

Which entries under [notify.custom.<name>] to run, and in what order. The same two startup errors apply.

template_dir (String) — Default: "" | Env: ACME_PROXY_NOTIFY__TEMPLATE_DIR

Directory searched for template overrides. Lookup is per file, so overriding one message (say email/certificate_issued.body.j2) leaves every other message at its compiled-in default. Empty means defaults only.

Each backend additionally takes its own events list and timeout_ms; see the backend pages.

Expiry digest

A certificate approaching its notAfter is the one thing the events above cannot report: nothing happens when a certificate is a fortnight from expiring. The [notify.expiry] table adds a periodic sweep that looks, and sends one message per profile listing what it found.

Deliberately not one message per certificate. A renewal is a new order, so the certificate it replaced still reaches its own expiry on schedule — a per-certificate reminder therefore fires for every certificate the CA has ever issued, on its way out, in exactly the deployments where the automation is working. Instead, each entry in the digest says whether something has already taken its place, and the entries where nothing has are the ones worth acting on.

That annotation is drawn from two signals, and the message says which was used: the successor order’s own replaces field (RFC 9773 §5 — exact, but only from clients that send one), or a later, unrevoked certificate of the same account covering all of the same names. Both are deliberately narrow. A certificate wrongly marked as already renewed is one an operator skips over while it lapses; one wrongly left unmarked is a line of noise.

expiry.lead_days (Integer) — Default: 0 | Env: ACME_PROXY_NOTIFY__EXPIRY__LEAD_DAYS

How far ahead to look. 0 is off — the sweep is never scheduled at all, the same shape audit.retention_days and jobs.retention_days use.

expiry.interval_days (Integer) — Default: 7 | Env: ACME_PROXY_NOTIFY__EXPIRY__INTERVAL_DAYS

How often the digest is sent. There is deliberately no per-certificate rate limit beside it: the digest is the rate limit. A digest with nothing to report is not sent, so the absence of a message is what “everything is renewed” looks like.

expiry.max_entries (Integer) — Default: 50 | Env: ACME_PROXY_NOTIFY__EXPIRY__MAX_ENTRIES

The most certificates one message lists. The number that matched is carried whole regardless, so a truncated digest still says how many it did not name.

The schedule is a row in the durable job queue rather than a timer, so it survives a restart: a server restarting more often than interval_days still sends its digest on time instead of resetting the clock each start.

Delivery semantics

Dispatch is fire-and-forget: the event is written to the durable job queue and the ACME response proceeds immediately. A notification backend can never delay or fail the request that triggered it.

Delivery itself is a notify_deliver job, one row per backend per event, so one flaky webhook is retried without re-sending through an email backend that already succeeded. Two consequences worth planning around:

  • A row outlives the process that wrote it. A notification generated moments before a restart is delivered by whoever starts next, rather than lost. There is no drain at shutdown to configure or wait for.
  • A failure is retried, unless it never could have worked. A refused SMTP connection, a timeout, a 429 or a 5xx from a webhook goes back in the queue under jobs.max_attempts and the shared backoff. A template that does not render, a url that does not parse and any other 4xx are refused on the first attempt — retrying would reach the same answer four more times and delay the log line saying so.

Every attempt logs notify_delivered or notify_delivery_failed. When the attempts run out, one notify_delivery_abandoned says the notification is genuinely lost — that is the line to alert on. Because the custom backend’s contract is an exit code with no way to say “never retry”, every failure of a custom script is treated as retryable.