Certificate Expiry & Logs.
Self-hosted SSL/TLS certificate monitor: a single binary and a single YAML file. Probes your endpoints on a schedule and delivers templated webhook alerts when a certificate is expiring, expired, invalid, or TLS becomes unavailable. Optionally exposes Prometheus metrics whose certificate metric names match ssl_exporter.
- Certificate checks: expiry (leaf and intermediates), chain of trust, hostname/SAN match, configurable warning/critical thresholds per target.
- STARTTLS:
smtp,imap,pop3,ftp,postgres— plus implicit TLS for everything else. A server that stops offering STARTTLS is reported (tls_unavailable), which also catches STARTTLS-stripping. - Private infrastructure: custom CA bundle per target (
ca_file),insecuremode that still checks expiry,servernamedecoupled from the connect address (probe via IP/bastion, verify the public name via SNI). Each notifier takes the sameca_file/insecureoptions for a receiver behind an internal CA. - Multiple notifiers with fan-out: define several named
notifiers, each with its own url, template, headers, delivery/retry policy and severity floor; attach each target to one or more viatarget_defaults.notifiersor a per-targetnotifiers. A target fans out to all its notifiers off one decision. Delivery is isolated per notifier — a down or slow endpoint can't delay another's alerts. - Per-notifier
min_severity: a stateless delivery floor. A channel carries only alerts at or above its floor (defaultwarning= everything); a problem dropping below the floor reads as a recovery for that channel. So an always-on SSL chat can take every alert while a shared pager takes onlyemergency, from the same target — no severity routing, each channel a self-consistent stream. - Sane alerting: alert on the transition into a bad state, periodic
reminders (
alert_repeat_intervalon the target, per-severity so the cadence tightens as severity rises), recovery notices, delivery retries with exponential backoff, connection retries before declaring a target unreachable. - Structured webhook bodies: write the message as a YAML
body:, not hand-built JSON — certel renders each value and marshals it, escaping for free. One${namespace.path}syntax covers both${alert.Path}data and${env.VAR}secrets (in the url, headers and body), so secrets stay out of the config file. - Persistent state (SQLite): alert dedup state survives restarts — a known problem is not re-alerted after a restart, and a problem fixed while the monitor was down still gets its recovery notice. Alerts are queued in a durable outbox and delivered at least once, so a crash between deciding to alert and sending re-sends on restart rather than staying silent. Probe results and alert events are kept as queryable append-only logs with configurable retention.
- Observability:
/metrics(Prometheus, ssl_exporter-compatible names),/healthz(scheduler progress and database reachability), structured logs.
make build # binaries land in bin/
cp config.example.yaml config.yaml # edit targets and alert endpoint
export ALERT_TOKEN=... # if referenced in headers
bin/certel validate-config config.yaml
bin/certel monitor -config config.yamlFor a one-off check of a single endpoint — no config file, no database, no
alerts — use certel check; the verdict is printed as JSON:
bin/certel check example.com
bin/certel check -protocol smtp -warning-days 14 mail.example.com:587The exit code follows the severity (0 ok, 1 warning, 2 critical), so the
command drops straight into scripts and CI. -servername, -ca-file,
-insecure, -timeout and -retries mirror the per-target config options;
run certel check -h for the full list.
Or with Docker:
docker build --build-arg VERSION=$(git describe --tags --always --dirty) -t certel .
docker run -v $PWD/config.yaml:/etc/certel/config.yaml \
-v certel-data:/opt/certel/db \
-e ALERT_TOKEN -p 8880:8880 certelThe VERSION build-arg stamps the version reported by certel version, the
startup log and /healthz; omit it and the image reports dev.
The /opt/certel/db volume holds the SQLite database (alert state and
logs); without it a container recreation loses dedup state and re-sends
alerts for known problems.
See config.example.yaml for the annotated reference. Minimal config:
notifiers:
default:
url: https://example.com/alert
body:
host: ${alert.Host}
status: ${alert.Status}
message: ${alert.Message}
target_defaults:
notifiers: [default] # every target fans out here unless it overrides
targets:
- address: example.com # port defaults per protocol (tls -> 443)
- address: mail.example.com:587
protocol: smtpEach target must resolve to at least one notifier — set
target_defaults.notifiers (as above) or a per-target notifiers:; there is no
implicit default even with one notifier defined. A single notifier is still a
one-element list (notifiers: [default]). A target with several notifiers fans
out to all of them. See
config.example.yaml for a setup where a critical database
pages on emergency (via a min_severity: emergency pager) while everything
posts to chat.
The repeat cadence is alert_repeat_interval on the target (or
target_defaults), not on the notifier — a persisting problem is what drives a
reminder, and every notifier a target fans out to shares that one clock. It takes
a single duration (same cadence for all severities) or a complete per-severity
map ({warning, critical, emergency}) so the reminder tightens as severity
rises; each entry must be at least probe.check_interval. The built-in default
is a flat 24h; the per-severity map is the opt-in upgrade, not the default.
Any entry (or the scalar) may be never — alert once on entering that severity
and never remind while it is held; escalation to a higher severity still fires
immediately (e.g. {warning: never, critical: 1d, emergency: 1h}).
A notifier's body: is a YAML structure whose string values carry
${alert.Path} references; certel renders each and marshals the whole thing to
JSON, so quotes and newlines are escaped automatically — never hand-build JSON. A
value that is exactly one ${alert.X} reference keeps its native JSON type
(days_left: ${alert.DaysLeft} → a number); a reference inside a larger string
yields a string. ${env.VAR} interpolates a secret (resolved once at startup) in
the url, headers or body; write a literal ${ as $${.
An optional recovery_body: is a sparse override deep-merged onto body when the
alert is a recovery — name only the keys that differ; the rest is inherited.
Timestamp fields take an optional | format suffix: a preset (date, datetime,
time, human, rfc3339) or a strftime pattern (%Y-%m-%d).
Fields: Host, Address, Port, Protocol, Status, Severity,
Message, Recovered, DaysLeft, CheckedAt, and under Cert.: Subject,
SubjectCN, Issuer, IssuerCN, IssuerOrg, NotBefore, NotAfter,
EarliestNotAfter, SigAlg, Serial, SANs. Cert.* renders empty when the
handshake never completed.
DaysLeft and the Cert.* fields are only meaningful when a certificate was
actually inspected; for unreachable and tls_unavailable alerts there is no
expiry to report and DaysLeft renders 0. Route on severity with separate
notifiers (min_severity) rather than branching inside a body — there is no
inline control flow.
| Status | Severity | Meaning |
|---|---|---|
ok |
ok | Verified, expiry beyond warning_days |
expiring_soon |
warning / critical | Expires within warning_days / critical_days |
expired |
emergency | A certificate in the presented chain has expired |
invalid |
emergency | Untrusted chain or hostname mismatch |
weak_signature |
critical | Chain unverifiable — a certificate is signed with a deprecated algorithm (e.g. SHA-1); expiry is still reported |
tls_unavailable |
critical | Server did not offer STARTTLS |
unreachable |
critical | Connection/protocol failure after retries — the certificate could not be inspected |
A threshold fires when strictly fewer than N days remain: warning_days: 30
alerts once fewer than 30 days are left, critical_days: 7 once fewer than 7.
Whole days are counted truncated toward zero, so the decision matches a
Prometheus line at N * 86400 over
certel_cert_expiry_timestamp_seconds - time() (up to probe staleness).
The probe accepts TLS 1.0 and above so legacy endpoints (old appliances, embedded management interfaces, legacy mail servers) can still be monitored for expiry; trust is verified manually regardless of the negotiated version.
A network blip and a genuinely dead service look identical to the prober, so a
transition into or out of a network-shaped status (unreachable,
tls_unavailable) is only alerted on after flap_streak consecutive cycles
agree — default 2, set per target or in target_defaults. A single bad cycle
that clears on the next one produces no alert and no recovery notice; a real
outage alerts one cycle later than before. flap_streak: 1 restores
alert-on-first-cycle. The debounce is symmetric: leaving a confirmed
unreachable state also needs flap_streak healthy cycles, so a
down-up-down flap does not emit an alert/recovery pair per tooth.
Fact statuses read from a successfully retrieved certificate (expired,
invalid, weak_signature, expiring_soon) have no such noise source and
always alert on the first observation, whatever flap_streak is set to.
The counter lives in memory but is rebuilt from probe_log on startup, so a
restart mid-confirmation resumes the count instead of restarting from zero. It
is alert policy only: ssl_probe_success and certel_probe_severity still
move on the first failed cycle, so the metrics never lag the probe.
A SQLite database (database.path; by default db/certel.sqlite
next to the binary, the directory is created automatically) stores:
alert_state— per-target alert dedup and repeat-timer state, restored on startup. This is why a restart neither re-alerts a known problem nor swallows the recovery notice for one fixed while the monitor was down. State for targets removed from the config is pruned at startup.notification_outbox— pending-delivery queue, tagged with the targetnotifier. Each notification is enqueued in the same transaction that updatesalert_state, so a crash between deciding to alert and sending cannot lose it: on restart each notifier's dispatcher re-sends whatever is still queued for it. Deliveries drain in order per target and a row is removed only once sent, so alerts are delivered at least once. Not pruned by age — but rows for a notifier no longer in the config (renamed or removed) are dropped at startup with a logged count, since their body was frozen with the old notifier's template; a dropped recovery is genuinely lost, while a still-present problem re-alerts withinalert_repeat_interval.probe_log— append-only log of every check: status, severity, days left, expiry, duration, attempts.alert_log— append-only history of alert events, one row per delivery (a fanned-out alert writes one row per firing notifier), recorded when the alert is decided; it records what happened and which notifier it reached — the status/severity that channel saw — not how it was delivered.
Probe and alert log entries older than database.probe_log_retention and
database.alert_log_retention (each default 90 days) are pruned hourly. The full schema with per-column notes is documented in
docs/static/schema.dbml. The file is safe to inspect while the
monitor runs:
sqlite3 db/certel.sqlite \
"SELECT datetime(checked_at, 'unixepoch'), address, status, days_left
FROM probe_log ORDER BY checked_at DESC LIMIT 20"certel monitor optionally exposes Prometheus metrics on /metrics, with the
certificate metric names matching
ssl_exporter so alert
expressions and panels port over.
The ssl_* names match ssl_exporter, but the label model does not:
ssl_exporter is scraped once per probed endpoint and carries identity in the
instance label attached by relabeling, while certel serves every target from
a single scrape and attaches host/address/protocol/servername labels
itself. Dashboards and alert rules built for ssl_exporter need their queries
rewritten:
ssl_cert_not_after{instance="$target"} # ssl_exporter
ssl_cert_not_after{address="$target"} # certel
The full surface — every metric, the naming and label policy, per-metric semantics, absence rules, and what was deliberately left out — is in the metrics reference.
Tab-completes certel's command names. Run the one line for your shell (assumes
certel is on your PATH), then restart the shell:
# zsh
echo 'source <(certel completion zsh)' >> ~/.zshrc
# bash
echo 'source <(certel completion bash)' >> ~/.bashrc
# fish
certel completion fish > ~/.config/fish/completions/certel.fishIt completes command names only — no flags, no target names. During local
development, replace certel with bin/certel.
When packaging lands the script will be shipped into the OS completion
directory (/usr/share/{bash-completion/completions,zsh/site-functions,fish/vendor_completions.d}),
so an installed package completes with no setup.
make build # build bin/certel and bin/notification-sink (version from git describe)
make test
make vet
make cleanTo watch alerts flow end to end, run the bundled webhook receiver and point
alert.url at it (the config.example.yaml placeholder already does):
go run ./cmd/notification-sink # listen on :9999, print alerts, respond 200
go run ./cmd/notification-sink -status 500 # simulate a broken endpoint to test retries
go run ./cmd/notification-sink -verbose # also print request headers (auth etc.)See the alternatives survey
for the study of existing tools and the reasoning behind building this one, and
the ADRs for the Architecture Decision Records — the why behind
the non-obvious design choices (delivery-only fan-out, the stateless
min_severity floor, the SQLite outbox model, the severity ladder, and the rest).
MIT — see LICENSE.