Scheduled checks · Expiry & revocation alerts · Fingerprint change detection · Deduplicated notifications · Console / email / Slack / webhook · Prometheus metrics
PyPI · Quick start · Configure · Alerts · Prometheus · Deployment · Issues
Continuous TLS certificate monitoring and alerting — the watch loop on top of certinspect.
certinspect tells you what a certificate looks like right now.
certminder runs it on a schedule, remembers what it saw last time, and
alerts you when a certificate is about to expire, gets revoked, changes
fingerprint, or becomes unreachable.
Why a separate tool
certminder never re-implements TLS or X.509 logic — that all lives in certinspect. certminder adds only what a monitor needs:
- a schedule (run once for cron, or loop as a daemon),
- state memory to detect changes between runs,
- deduplicated alerts (notify once per condition, recover once),
- pluggable notifiers (console, email, Slack, generic webhook),
- optional Prometheus metrics for the node_exporter textfile collector.
Install
pip install certminder # pulls in certinspect automatically
# or from source:
pip install -e '.[dev]'
Quick start
# inspect a single host ad hoc
certminder check example.com
# copy and edit the sample config, then:
certminder once -c certminder.yml # one cycle — ideal for cron
certminder run -c certminder.yml # run continuously as a daemon
certminder report -c certminder.yml # print the current problems (from state)
Configure
Everything is driven by a YAML file (see
certminder.example.yml):
interval: 6h
state_file: ~/.certminder/state.json
defaults:
verify: true
days: 30
critical_days: 15
notifiers:
- type: console
- type: slack
webhook_url: "https://hooks.slack.com/services/XXX/YYY/ZZZ"
min_severity: critical # only critical events reach Slack
- type: email
host: smtp.example.com
port: 587
username: alerts@example.com
password: CHANGE_ME
from_addr: alerts@example.com
to: [ops@example.com]
targets:
- host: example.com
- host: api.example.com
port: 8443
- host: mail.example.com
starttls: smtp
- host: internal.example.lan
cafile: /etc/ssl/internal-ca.pem # verify against a private CA, not the public store
- host: short-lived.example.com
cab_forum: true # fail if validity exceeds today's CA/Browser Forum cap
- host: hardened.example.com
require_sct: true # require Certificate Transparency SCTs
require_must_staple: true # require the OCSP Must-Staple extension
min_tls_version: TLSv1.2 # require at least TLS 1.2
- host: strict.example.com
profile: strict # one-flag hardening bundle (lenient/standard/strict)
The opt-in policy checks (all raise POLICY_VIOLATION) are: cab_forum or
not_after_max (maximum validity), require_sct (Certificate Transparency),
require_must_staple (OCSP Must-Staple), and min_tls_version (minimum
negotiated TLS version). cab_forum and not_after_max are mutually
exclusive. A profile (lenient, standard or strict) applies a named
bundle of these checks in one line; any explicit check above overrides it.
Any notifier also accepts min_severity (info/warning/critical) to
receive only events at or above that level — e.g. keep everything on the console
but send only critical to Slack. Targets on an internal/private CA take
cafile:/capath: so the chain is verified against that bundle instead of the
public trust store, which avoids false CHAIN_UNTRUSTED alerts.
Grouping targets (shared settings)
Put targets that share settings — say a whole installation behind its own
internal CA — under a groups: entry instead of repeating the keys on every
target. Group-level keys apply to all of the group's targets; precedence is
defaults < group < per-target. Top-level targets: and groups: can coexist.
defaults:
verify: true
groups:
- name: Site A (internal CA)
cafile: /etc/certminder/site-a-ca.pem
targets:
- host: iap.site-a.lan
- host: trustapp.site-a.lan
- host: webauth.site-a.lan
cafile: /etc/certminder/other-ca.pem # per-target override wins
targets:
- host: public.example.com # ungrouped, uses only defaults
What it alerts on
Each certificate is inspected on every axis, so a certificate with several
faults raises one alert per problem (e.g. expired and an untrusted chain
give two separate events) — nothing is hidden behind a single headline status.
Every problem is deduplicated independently: it is notified once and clears with
its own RECOVERED event. When a new problem appears on a certificate its
full current set is re-shown together, so a fresh fault never hides the ones
already active. On (re)start the daemon reports every currently-active problem
once (startup_report, on by default), so a restart surfaces the current
picture instead of staying silent until the next change. Set renotify_after
(e.g. 24h) to re-alert a still-active problem periodically so a persistent
fault is never silent for long, and heartbeat (on by default) prints a
one-line summary after each cycle so a quiet daemon is visibly alive. Set
failure_threshold (e.g. 2) to require a problem to persist that many
consecutive cycles before it alerts, so a one-cycle network blip is dampened.
| Event | Severity | Trigger |
|---|---|---|
EXPIRING |
warning | within --days of expiry |
CRITICAL / EXPIRED |
critical | within critical_days, or already expired |
NOT_YET_VALID |
critical | validity period starts in the future |
REVOKED |
critical | OCSP/CRL says revoked (needs verify) |
CHAIN_UNTRUSTED |
critical | chain fails to validate |
HOSTNAME_MISMATCH |
critical | cert does not match the hostname |
POLICY_VIOLATION |
critical | fails an opt-in policy check (see below) |
WEAK_CRYPTO |
warning | small key or SHA-1/MD5 signature |
CHAIN_EXPIRING |
warning | an intermediate/root CA is expired or near expiry |
FINGERPRINT_CHANGED |
warning | fingerprint differs from last cycle |
UNREACHABLE |
critical | host/handshake failed |
RECOVERED |
info | a specific problem cleared |
Acknowledging known problems (expect)
Some problems are known and accepted: a test endpoint on a private CA that will
never be publicly trusted, a service that deliberately serves a shared
certificate, and so on. List those problem kinds per target in expect and
certminder stops alerting on them — while still alerting on anything else,
so a new, unexpected fault on the same host is never buried under the ones you
already know about.
targets:
- host: trustapp-cit.azero.veneto.it
expect: [chain_untrusted, hostname_mismatch] # known: private CA + shared cert
- host: internal.lab.example
expect: [chain_untrusted] # internal CA, expected
Accepted kinds are the alertable ones: expiring, critical, expired,
not_yet_valid, revoked, chain_untrusted, hostname_mismatch,
policy_violation, weak_crypto, chain_expiring, unreachable (an unknown
kind is a config error).
How it behaves:
- An expected problem raises no alert and is not tracked as an active alert — it is silently accepted.
- Any other problem on the same target still alerts normally (with
expect: [chain_untrusted], anEXPIREDon that host is still reported). - Remove a kind from
expectand it starts alerting again on the next cycle. expectsilences only the alerts (console/Slack/webhook/email and the startup digest). The Prometheuscertminder_certificate_problemmetric still reflects the real state, so Grafana keeps full visibility.
Each condition alerts once; certminder remembers it and stays quiet until it changes, then sends a single recovery notice.
Exit codes (once)
0— no events this cycle1— at least one event was emitted2— configuration error
Add --json to once to print a machine-readable summary of the cycle (one
entry per target plus the events) to stdout, handy for piping:
certminder once -c certminder.yml --json | jq '.targets[] | {target, status, days_to_expire}'
Prometheus metrics
Set prometheus_file in the config to a path inside the node_exporter
textfile collector
directory. certminder rewrites it atomically at the end of every cycle:
certminder_certificate_expiry_days{target="example.com:443",host="example.com",port="443",status="VALID"} 42
certminder_certificate_valid{...} 1
certminder_target_up{...} 1
certminder_certificate_problem{target="example.com:443",host="example.com",port="443",problem="chain_untrusted"} 1
certminder_certificate_problem{...,problem="expired"} 1
certminder_last_run_timestamp_seconds 1700000000
certminder_certificate_problem emits one series per active problem, so a
certificate with several faults is fully visible to Grafana/Alertmanager
instead of collapsing to the single status label — mirroring the per-problem
alerts. A healthy certificate emits no such series.
Example Alertmanager rules (per-problem, expiry, and a stalled-daemon guard):
groups:
- name: certminder
rules:
- alert: CertificateProblem
expr: certminder_certificate_problem > 0
for: 15m
annotations:
summary: "{{ $labels.problem }} on {{ $labels.target }}"
- alert: CertificateExpiringSoon
expr: certminder_certificate_expiry_days < 14
for: 1h
- alert: CertminderStalled
expr: time() - certminder_last_run_timestamp_seconds > 86400
Deployment
Ready-to-use units live in deploy/ plus a Dockerfile:
- systemd timer —
certminder.service+certminder.timerrun one cycle on a schedule (cron-style, recommended). - systemd daemon —
certminder-daemon.serviceruns therunloop under supervision. - cron —
certminder.cronfor hosts without systemd timers. - Docker — multi-stage build; mount your
certminder.ymlat/etc/certminder/certminder.ymland a volume at/var/lib/certminder.
Docker
Build the image:
docker build -t certminder .
Run a single cycle (cron-style — config and state mounted from the host):
docker run --rm \
-v "$PWD/certminder.yml:/etc/certminder/certminder.yml:ro" \
-v certminder-state:/var/lib/certminder \
certminder once -c /etc/certminder/certminder.yml
Run continuously as a daemon (this is the default CMD):
docker run -d --name certminder \
--restart unless-stopped \
-v "$PWD/certminder.yml:/etc/certminder/certminder.yml:ro" \
-v certminder-state:/var/lib/certminder \
certminder
The named volume certminder-state persists state.json and the Prometheus
file across restarts — keep it so deduplication survives container recreation.
The console notifier prints to stdout; read it with docker logs -f certminder
(timestamps from Docker with -t, or set timestamp: true on the console
notifier). The container runs in UTC.
Docker Compose
services:
certminder:
build: . # or: image: certminder
container_name: certminder
restart: unless-stopped
command: run -c /etc/certminder/certminder.yml
volumes:
- ./certminder.yml:/etc/certminder/certminder.yml:ro
- certminder-state:/var/lib/certminder
logging: # cap the daemon's logs so they don't grow without bound
driver: json-file
options:
max-size: "10m"
max-file: "5"
volumes:
certminder-state:
docker compose up -d # build (if needed) and start the daemon
docker compose logs -f certminder
docker compose up -d --build # rebuild after upgrading certminder/certinspect
docker compose down # stop and remove
Development
ruff check . && ruff format --check .
pytest -q
Tests mock the certinspect subprocess, so the suite never touches the network.
Support
If certminder is useful to you, the best ways to support it are:
- Star the repo to help others discover it
- Open an issue for bugs or ideas
- Send a pull request
- Share it with others who monitor TLS certificates
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file certminder-1.4.0.tar.gz.
File metadata
- Download URL: certminder-1.4.0.tar.gz
- Upload date:
- Size: 38.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1c08ad9734080b21c5aa1925f25c4bf700ed632bb77979106a8d7cfeaf195b84
|
|
| MD5 |
8c5ad44bf01586592e74d7cc056912df
|
|
| BLAKE2b-256 |
6eec8fe8e1e53753a017c95e6e954fc0d2e90099ccc9d92b088be27d3d803017
|
Provenance
The following attestation bundles were made for certminder-1.4.0.tar.gz:
Publisher:
publish.yml on mangrisano/certminder
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
certminder-1.4.0.tar.gz -
Subject digest:
1c08ad9734080b21c5aa1925f25c4bf700ed632bb77979106a8d7cfeaf195b84 - Sigstore transparency entry: 2290927131
- Sigstore integration time:
-
Permalink:
mangrisano/certminder@a665405ffd370e66a589c2a9eed81e74ab45183f -
Branch / Tag:
refs/tags/v1.4.0 - Owner: https://github.com/mangrisano
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a665405ffd370e66a589c2a9eed81e74ab45183f -
Trigger Event:
push
-
Statement type:
File details
Details for the file certminder-1.4.0-py3-none-any.whl.
File metadata
- Download URL: certminder-1.4.0-py3-none-any.whl
- Upload date:
- Size: 30.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
260de6be08ec6940516ccf14a6c5633abfb945219028368f9540b7a503ac1662
|
|
| MD5 |
fe3b317fedd4bcc6c309ef35b8032618
|
|
| BLAKE2b-256 |
9953c0ae61f9a99d1ff13adfe1e082dad8cd247a677d4b0afbf42d29d0bb2ae8
|
Provenance
The following attestation bundles were made for certminder-1.4.0-py3-none-any.whl:
Publisher:
publish.yml on mangrisano/certminder
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
certminder-1.4.0-py3-none-any.whl -
Subject digest:
260de6be08ec6940516ccf14a6c5633abfb945219028368f9540b7a503ac1662 - Sigstore transparency entry: 2290927183
- Sigstore integration time:
-
Permalink:
mangrisano/certminder@a665405ffd370e66a589c2a9eed81e74ab45183f -
Branch / Tag:
refs/tags/v1.4.0 - Owner: https://github.com/mangrisano
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a665405ffd370e66a589c2a9eed81e74ab45183f -
Trigger Event:
push
-
Statement type: