Build Automated TLS Certificate Expiry Monitoring on a $5 VPS
Caddy renews certificates automatically, but nothing tells you when renewal stops working. Build a 150-line certificate exporter with the Python stdlib, scrape it with Prometheus, and alert on time-to-expiry in seconds instead of integer days.
Two failures cost real money: a certificate that expires while nobody is watching, and an automatic renewal that broke weeks ago and only announces itself when the certificate lapses at 03:00. This guide builds the second pair of eyes. Caddy terminates TLS and renews on its own; a 150-line exporter reads the certificate that is actually served, exposes its expiry as a Prometheus gauge, and five alert rules turn that into a warning at 21 days, a critical at 7, and a page when the probe itself stops working. Everything below was run on real containers: Caddy 2.10.2, Prometheus 3.6.0, Python 3.11.15, OpenSSL 3.0.2, Docker 28.2.2. Verified 2026-10-20.
What we’re building
┌─────────────────────────────────────┐
browser ────────►│ Caddy :443 (ACME, auto-renew) │──► app :3000
└─────────────────────────────────────┘
▲
│ TLS handshake (what a browser sees)
┌──────────────┴──────────────────────┐
│ tls_exporter :9812 (systemd) │
│ socket probes + PEM file targets │
└──────────────▲──────────────────────┘
│ scrape /metrics
┌──────────────┴──────────────────────┐
│ Prometheus (container) + alert rules│
└─────────────────────────────────────┘
Four moving parts, one file of code:
- Caddy obtains and renews certificates. You never touch
certbot, cron entries or renewal hooks. tls_exporteropens a TLS connection to each target, reads the leaf certificate, and publishestls_cert_expiry_timestamp_seconds. It also reads PEM files from disk for certificates that are not served yet (a private CA root, a certificate staged for a cutover).- Prometheus scrapes that endpoint and evaluates rules.
- Alert rules that fire on seconds remaining, not on an integer day counter — the difference matters at the bottom of this article.
The exporter is deliberately not blackbox_exporter. Blackbox gives you probe_ssl_earliest_cert_expiry and little else: no issuer label, no “the chain does not validate but the certificate is still readable”, no file targets, and a config file in a language nobody remembers. A single Python file you can read in a minute is easier to keep alive for five years.
What you’ll learn
- Why the certificate your ACME client thinks it has is not the certificate being served
- How to read
notAfteroff a live handshake in Python without installing anything - How to keep reporting expiry for a self-signed or untrusted certificate instead of losing the datapoint
- The exact Prometheus scrape setup that reaches a host-level service from inside a container
- Alert rules that stay quiet on 12-hour internal certificates and still catch a 90-day certificate at 21 days
- Measured probe latency, memory and alert behaviour on a 1 GB VPS budget
Prerequisites
- Docker 28.2.2 with Compose v2 (verified)
- Python 3.11 or newer on the host (verified on 3.11.15) — stdlib only, no pip
opensslCLI on the host (verified 3.0.2; shipped by theopensslpackage on Debian and Ubuntu)- Caddy 2.10.2 (verified) or any reverse proxy — the exporter does not care who terminates TLS
- A domain pointing at the VPS, if you want the real ACME path rather than the local test CA
1. Renewal status is not expiry status
Most TLS automation guides stop at “Caddy does it for you”, which is true until it isn’t. The failure modes are all silent:
| Failure | What you see | What actually happened |
|---|---|---|
| ACME challenge can’t be solved | Renewal warning buried in container logs | Port 80 blocked by a firewall change, or a DNS record moved |
| Rate limit hit after repeated failures | One line per hour in logs | Five failed attempts turned into a week-long block |
| Certificate renewed, old one still served | Nothing at all | Reload never happened; the process holds the old cert in memory |
| Private CA rotated | Nothing on the public site | An internal service starts rejecting connections with x509: certificate signed by unknown authority |
| Clock or storage drift in the container | Renewal “succeeds”, cert is already stale | Container clock skew from a frozen VM |
None of these change the one number that matters: notAfter on the certificate a client actually receives. So measure that, from the outside, on a schedule. That is the whole design.
2. Terminate TLS with Caddy
Caddyfile — the production variant. Caddy obtains a Let’s Encrypt certificate on first request and renews it automatically; the explicit email line is only there for ACME account notices.
{
email [email protected]
admin off
}
example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}
For the local test in this guide you do not need a domain or a public IP. Add local_certs and Caddy issues from its own CA instead of ACME:
{
local_certs
admin off
storage file_system /data
}
https://localhost:8443 {
respond "caddy internal CA ok"
}
Run it with the data directory bind-mounted, so the CA and issued certificates survive restarts:
docker run -d --name tls-caddy \
-p 127.0.0.1:18443:8443 \
-v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "$PWD/caddy-data:/data" \
caddy:2.10-alpine
Two things that will bite you here, both observed:
- Caddy listens on
8443inside the container. If8443is already taken on the host, map another host port (18443:8443) — otherwisedocker runfails withfailed to bind host port for 127.0.0.1:8443:172.17.0.3:8443/tcp: address already in use. - With
storage file_system /data, the CA root lands at/data/pki/authorities/local/root.crt(not/data/caddy/pki/...). Copy it out for verification:
docker exec tls-caddy cat /data/pki/authorities/local/root.crt > certs/caddy-root.crt
curl -s -o /dev/null -w '%{http_code}\n' --cacert certs/caddy-root.crt \
--resolve localhost:18443:127.0.0.1 https://localhost:18443/
200 on this host, measured 477 ms after docker run returned (one timed run: container start plus certificate reuse from the persisted data volume).
3. The exporter
One file, standard library plus the openssl CLI. Save it as /opt/tls-exporter/exporter.py.
#!/usr/bin/env python3
"""tls_exporter.py - certificate expiry metrics with the Python stdlib + openssl."""
import json
import os
import socket
import ssl
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TARGETS_FILE = os.environ.get("TARGETS_FILE", "targets.json")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "9812"))
INTERVAL = int(os.environ.get("PROBE_INTERVAL", "300"))
PROBE_TIMEOUT = float(os.environ.get("PROBE_TIMEOUT", "5"))
RESULTS = {}
LOCK = threading.Lock()
def cert_fields(pem_text):
proc = subprocess.run(
["openssl", "x509", "-noout", "-subject", "-issuer", "-startdate", "-enddate"],
input=pem_text, capture_output=True, text=True, timeout=10, check=True,
)
fields = {}
for line in proc.stdout.splitlines():
key, _, value = line.partition("=")
fields[key.strip().lower()] = value.strip()
return fields
def probe_file(path):
with open(path, "rb") as fh:
blob = fh.read()
if b"BEGIN CERTIFICATE" in blob:
return blob.decode("ascii", "ignore")
return ssl.DER_cert_to_PEM_cert(blob)
def probe_socket(host, port, servername, ca_file):
"""Return (pem, verify_ok, verify_error). Falls back to an unverified
handshake so expiry is still reported when the chain does not validate."""
try:
ctx = ssl.create_default_context(cafile=ca_file)
with socket.create_connection((host, port), timeout=PROBE_TIMEOUT) as raw:
with ctx.wrap_socket(raw, server_hostname=servername) as tls:
return ssl.DER_cert_to_PEM_cert(tls.getpeercert(True)), 1, ""
except ssl.SSLCertVerificationError as exc:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), timeout=PROBE_TIMEOUT) as raw:
with ctx.wrap_socket(raw, server_hostname=servername) as tls:
pem = ssl.DER_cert_to_PEM_cert(tls.getpeercert(True))
return pem, 0, exc.verify_message
def probe(target):
started = time.monotonic()
result = {"ok": 0, "verify_ok": None, "error": ""}
try:
if "file" in target:
pem = probe_file(target["file"])
else:
pem, verify_ok, err = probe_socket(
target["host"], int(target["port"]),
target.get("servername", target["host"]), target.get("ca_file"),
)
result["verify_ok"] = verify_ok
result["error"] = err
fields = cert_fields(pem)
result["not_after"] = ssl.cert_time_to_seconds(fields["notafter"])
result["not_before"] = ssl.cert_time_to_seconds(fields["notbefore"])
result["subject"] = fields.get("subject") or "-"
result["issuer"] = fields.get("issuer") or "-"
result["ok"] = 1
except Exception as exc: # noqa: BLE001 - one broken target must not kill the loop
result["error"] = result["error"] or f"{type(exc).__name__}: {exc}"
result["duration"] = time.monotonic() - started
result["ts"] = time.time()
return result
def refresh(targets):
for target in targets:
res = probe(target)
with LOCK:
RESULTS[target["name"]] = res
def loop(targets):
while True:
refresh(targets)
time.sleep(INTERVAL)
def esc(value):
return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
def render():
now = time.time()
out = [
"# HELP tls_cert_expiry_timestamp_seconds Unix timestamp of the leaf certificate notAfter.",
"# TYPE tls_cert_expiry_timestamp_seconds gauge",
]
with LOCK:
snapshot = dict(RESULTS)
for name in sorted(snapshot):
res = snapshot[name]
labels = f'target="{esc(name)}"'
if res["ok"]:
pair = f'{labels},subject="{esc(res["subject"])}",issuer="{esc(res["issuer"])}"'
out.append(f"tls_cert_expiry_timestamp_seconds{{{pair}}} {res['not_after']:.0f}")
out.append(f"tls_cert_not_before_timestamp_seconds{{{pair}}} {res['not_before']:.0f}")
out.append(f"tls_cert_days_remaining{{{labels}}} {int((res['not_after'] - now) // 86400)}")
out.append(f"tls_cert_expired{{{labels}}} {1 if res['not_after'] <= now else 0}")
if res["verify_ok"] is not None:
out.append(f"tls_cert_verify_ok{{{labels}}} {res['verify_ok']}")
out.append(f"tls_probe_success{{{labels}}} {res['ok']}")
out.append(f"tls_probe_duration_seconds{{{labels}}} {res['duration']:.6f}")
out.append(f"tls_probe_last_run_timestamp_seconds{{{labels}}} {res['ts']:.0f}")
if res["error"]:
out.append(f'tls_probe_error{{{labels},error="{esc(res["error"])}"}} 1')
return "\n".join(out) + "\n"
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 - stdlib naming
if self.path == "/healthz":
body, code = b"ok\n", 200
elif self.path == "/metrics":
body, code = render().encode(), 200
else:
body, code = b"not found\n", 404
self.send_response(code)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # silence per-request logging
pass
if __name__ == "__main__":
with open(TARGETS_FILE) as fh:
configured = json.load(fh)["targets"]
refresh(configured)
threading.Thread(target=loop, args=(configured,), daemon=True).start()
print(f"tls_exporter: {len(configured)} targets, listening on :{LISTEN_PORT}", flush=True)
ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever()
Four decisions worth explaining:
Handshake in Python, X.509 parsing in openssl. The ssl module will hand you the DER bytes but not the validity dates when verification is disabled — getpeercert() returns {} in that case. Shelling out to openssl x509 keeps the file dependency-free and works identically on a PEM file. Cost: one subprocess per probe, measured at 40.8 ms including the fork.
Never lose a datapoint because the chain is untrusted. The first handshake uses create_default_context(), which validates against the system store and the target’s own ca_file. If validation raises SSLCertVerificationError, the exporter retries with CERT_NONE and still reports expiry, setting tls_cert_verify_ok=0 and keeping the reason in a label. A self-signed certificate that expires tomorrow is exactly the thing you want an alert about, not a probe failure you have to debug.
Probe in a background thread, serve from memory. A scrape blocks for at most the last probe’s duration, never for a 5-second connect timeout across eight targets. The trade-off is up to PROBE_INTERVAL of staleness; five minutes is fine for certificates, and tls_probe_last_run_timestamp_seconds makes the staleness visible.
Emit tls_probe_error with the reason, not just a boolean. On the first run, TLSProbeFailing firing with error="ConnectionRefusedError: [Errno 111] Connection refused" tells you what happened. A silent 0 does not.
4. Targets
/opt/tls-exporter/targets.json:
{
"targets": [
{ "name": "www.example.com", "host": "127.0.0.1", "port": 443,
"servername": "www.example.com" },
{ "name": "api.example.com", "host": "api.example.com", "port": 443 },
{ "name": "internal-ca", "host": "10.0.0.10", "port": 8443,
"servername": "vault.internal", "ca_file": "/opt/tls-exporter/private-ca.pem" },
{ "name": "caddy-root-ca",
"file": "/data/caddy/pki/authorities/local/root.crt" }
]
}
servernameis the SNI name and the hostname checked during verification. Use it whenever you connect to127.0.0.1— otherwise you validate the certificate againstlocalhostand get a spurious mismatch.ca_filepins a private CA for that one target. Relative paths resolve against the process working directory, so absolute paths are safer under a service manager.- A
filetarget skips the socket entirely and parses the first certificate in the PEM. Use it for the CA root itself (measured:CN = Caddy Local Authority - 2026 ECC Root, 3,599 days remaining) or for a certificate staged before a cutover.
A file target does not emit tls_cert_verify_ok, because nothing was verified — only parsed. Alerts must not assume that label exists for every series.
5. Run it as a service
# /etc/systemd/system/tls-exporter.service
[Unit]
Description=TLS certificate expiry exporter
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=tls-exporter
WorkingDirectory=/opt/tls-exporter
Environment=TARGETS_FILE=/opt/tls-exporter/targets.json
Environment=LISTEN_PORT=9812
Environment=PROBE_INTERVAL=300
ExecStart=/usr/bin/python3 /opt/tls-exporter/exporter.py
Restart=always
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
[Install]
WantedBy=multi-user.target
The unit was checked with systemd-analyze verify: no errors on this unit (it complains about unrelated units on the host, ignore those). ProtectSystem=strict is safe here because the service only reads files and opens sockets; if you add a file target outside /opt, whitelist it with ReadOnlyPaths=.
Bind to all interfaces, not 127.0.0.1: Prometheus runs in a container and reaches the host through the bridge gateway, so a loopback-only listener is unreachable. The firewall, not the bind address, is what keeps 9812 private.
To deploy the unit on a fresh box:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin tls-exporter
sudo mkdir -p /opt/tls-exporter && sudo chown tls-exporter: /opt/tls-exporter
sudo systemctl daemon-reload
sudo systemctl enable --now tls-exporter
curl -s localhost:9812/healthz # ok
curl -s localhost:9812/metrics | head
6. Scrape it from Prometheus
prometheus.yml:
global:
scrape_interval: 5s
evaluation_interval: 5s
rule_files:
- rules.yml
scrape_configs:
- job_name: tls
static_configs:
- targets: ["host.docker.internal:9812"]
compose.yml — the extra_hosts line is what makes host.docker.internal resolve on Linux (it is built into Docker Desktop, not into the Linux daemon):
services:
prometheus:
image: prom/prometheus:v3.6.0
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=15d
ports:
- "127.0.0.1:9090:9090"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./rules.yml:/etc/prometheus/rules.yml:ro
- prometheus-data:/prometheus
volumes:
prometheus-data:
docker compose up -d
curl -s 'http://127.0.0.1:9090/api/v1/query?query=up' # job="tls" must be 1
A 5-second scrape interval is for the demonstration. On a real VPS use 30s — certificate expiry does not move that fast, and every scrape is a full TLS handshake per target.
7. Alert rules that do not drown you
rules.yml:
groups:
- name: tls
rules:
- alert: TLSCertExpiringSoon
expr: (tls_cert_expiry_timestamp_seconds - time()) < 21 * 86400
for: 10s
labels:
severity: warning
- alert: TLSCertExpiryCritical
expr: (tls_cert_expiry_timestamp_seconds - time()) < 7 * 86400
for: 10s
labels:
severity: critical
- alert: TLSCertAlreadyExpired
expr: (tls_cert_expiry_timestamp_seconds - time()) < 0
for: 0m
labels:
severity: critical
- alert: TLSProbeFailing
expr: tls_probe_success == 0
for: 30s
labels:
severity: warning
- alert: TLSCertNotTrusted
expr: tls_cert_verify_ok == 0 and tls_probe_success == 1
for: 10s
labels:
severity: info
for: 10s exists only to make the test fast; in production use for: 15m so a transient network blip at 04:00 does not page anyone. The 21 * 86400 form is deliberate: Prometheus evaluates seconds, so expressing the threshold in days keeps the rule readable without converting the metric.
Alertmanager is the next hop — route severity: critical to a phone and leave warning in email. That config depends on your receiver and is out of scope here; the Prometheus side above is everything this guide tests. If you want zero extra moving parts, Prometheus’ own ALERTS series can be forwarded by a webhook bridge, at the cost of grouping and silences.
8. Verify it against real certificates
The test bed: two openssl s_server instances with self-signed certificates (90 days and 2 days), one Caddy container on its internal CA, one real public host, and one port with nothing listening. Everything below is from two full runs on a shared 12-vCPU host.
openssl req -x509 -newkey rsa:2048 -nodes -keyout certs/k90.pem -out certs/c90.pem \
-days 90 -subj "/CN=test90.local"
openssl req -x509 -newkey rsa:2048 -nodes -keyout certs/k2.pem -out certs/c2.pem \
-days 2 -subj "/CN=test2.local"
openssl s_server -accept 8444 -cert certs/c90.pem -key certs/k90.pem -www -quiet &
openssl s_server -accept 8445 -cert certs/c2.pem -key certs/k2.pem -www -quiet &
TARGETS_FILE=config/targets.json LISTEN_PORT=9812 PROBE_INTERVAL=3600 python3 exporter.py
Probe duration, run 1 / run 2 (one probe per target; PROBE_INTERVAL pushed to an hour so nothing refreshes mid-measurement):
| Target | Kind | tls_probe_duration_seconds |
verify_ok |
days_remaining |
|---|---|---|---|---|
dead-backend (127.0.0.1:9999) |
socket, refused | 0.0063 / 0.0111 | — (probe failed) | — |
site.example.internal |
Caddy internal CA | 0.0318 / 0.0305 | 1 | 0 |
short-lived |
self-signed, 2 days | 0.0434 / 0.0385 | 0 | 1 |
legacy-https |
self-signed, 90 days | 0.0404 / 0.0392 | 0 | 89 |
public-reference (example.com) |
public CA | 0.1092 / 0.1144 | 1 | 66 |
caddy-root-ca |
PEM file | 0.0408 (separate run) | — (file) | 3599 |
Three findings that only show up when you actually run it:
A 90-day certificate reports 89 days, and a 2-day one reports 1. days_remaining is floor division, so the label is a whole-day countdown, not a deadline. Never build an alert on a “days” threshold unless you accept up to 24 hours of slop.
Caddy’s internal CA certificate reports days_remaining 0 and has no subject CN. The leaf is valid for exactly 12 hours: notBefore=Oct 20 08:01:46 2026 GMT, notAfter=Oct 20 20:01:46 2026 GMT, issuer CN = Caddy Local Authority - ECC Intermediate, subject empty because the identity lives in the SAN only. Any < 7 days rule fires on it permanently. That is the trap in the next section.
The unverified fallback is what makes the self-signed case useful. Both self-signed targets produced tls_cert_verify_ok 0 with tls_probe_error{error="self-signed certificate"} 1 while still reporting expiry. example.com produced verify_ok 1, issuer C = US, O = SSL Corporation, CN = Cloudflare TLS Issuing ECC CA 3, 66 days out. Same code path, both answers.
Alert evaluation, both runs, for: 10s, 45 seconds after start:
TLSCertExpiringSoon x2 (short-lived 1 day, site.example.internal 12h)
TLSCertExpiryCritical x2 (same two)
TLSCertNotTrusted x2 (legacy-https, short-lived)
TLSProbeFailing x1 (dead-backend)
Seven firing alerts, identical in both runs, with up{job="tls"} = 1. The two criticals are the false positive this guide tells you to remove.
Resource footprint, measured with docker stats and ps:
| Process | Memory |
|---|---|
tls_exporter (5 socket targets) |
25.2–25.7 MiB RSS |
| Prometheus 3.6.0 | 21.4 MiB |
| Caddy 2.10.2 | 16.1 MiB |
prom/prometheus:v3.6.0 image |
313 MB on disk |
caddy:2.10-alpine image |
58.6 MB on disk |
The exporter needed 2,118 ms from launch to the first 200 on /metrics, almost all of it the external probe blocking the startup refresh. That is why the server starts after one synchronous refresh: the first scrape is already correct instead of empty.
9. The 12-hour trap
tls_cert_days_remaining is the metric everybody writes first and the one that causes the most noise. With Caddy’s internal CA (12-hour certificates), local_certs, or any short-lived private CA — anything under 48 hours — the gauge sits at 0 and a day-based rule fires forever.
Alert on the timestamp, scoped by the certificate’s own lifetime:
- alert: TLSCertExpiryCritical
expr: (tls_cert_expiry_timestamp_seconds - time()) < 7 * 86400
and on(target) (tls_cert_expiry_timestamp_seconds - tls_cert_not_before_timestamp_seconds) > 30 * 86400
for: 15m
labels:
severity: critical
The second clause keeps the rule to certificates whose lifetime exceeds 30 days, so a 12-hour internal certificate is excluded while a 90-day public one is still caught at 7 days. Equivalently, keep two rule groups: one for public targets with the 21/7-day thresholds, one for internal ones with a shorter window. What matters is that the choice is explicit — a rule that fires on 60% of your targets gets muted in a week, and then the 90-day certificate that matters lapses unnoticed.
For genuinely short-lived certificates, alert on a fraction of the lifetime instead:
expr: (tls_cert_expiry_timestamp_seconds - time())
/ (tls_cert_expiry_timestamp_seconds - tls_cert_not_before_timestamp_seconds) < 0.25
That works whether the certificate lives 12 hours or 90 days: it fires in the last quarter of its valid window, however long that window is.
10. Put it on a VPS
# Debian 13, 1 vCPU / 1 GB
sudo apt update && sudo apt install -y python3 openssl
sudo ufw allow 80,443/tcp # ACME needs 80 (HTTP-01) and 443
sudo ufw deny 9812/tcp # exporter stays host-only
The reverse proxy, the app and Prometheus stay on the same box; the exporter is one more service. Keep PROBE_INTERVAL=300 with a 30-second scrape and the whole thing costs a few seconds of CPU per hour.
Cost
Assumptions: one VPS, certificates from Let’s Encrypt (free), no managed monitoring, Prometheus with 15-day retention for a handful of series.
| Item | Monthly |
|---|---|
| VPS 1 vCPU / 1 GB / 20 GB (Caddy + app + Prometheus) | €4–6 |
| Domain | ~€0.85 (€10/year) |
| Let’s Encrypt certificates | €0 |
| Prometheus storage: a few KB per series over 15 days | €0 |
| Total | €5–7/month |
The measured 63 MiB across exporter, Prometheus and Caddy is what makes a 1 GB box viable: the monitoring stack is noise next to a Node or Python app. If you already run Prometheus for uptime checks, the marginal cost of this is 25 MiB and one more scrape target.
Environment variables
| Variable | Required | Description |
|---|---|---|
TARGETS_FILE |
no (default targets.json) |
Path to the JSON target list |
LISTEN_PORT |
no (default 9812) |
HTTP port for /metrics and /healthz |
PROBE_INTERVAL |
no (default 300) |
Seconds between probe cycles |
PROBE_TIMEOUT |
no (default 5) |
Connect and handshake timeout per target |
Production checklist
-
targets.jsoncovers every hostname you serve, plus every private CA root you depend on -
servernameset explicitly for any target reached by IP -
ca_filepinned for private CAs;verify_ok 0treated as a real alert, not noise - Exporter binds
0.0.0.0:9812but the firewall allows only Prometheus -
PROBE_INTERVAL≤ 300 s and scrape interval ≤ 60 s - Day-based thresholds replaced by the lifetime filter or a fraction-of-lifetime rule
-
for:on every rule ≥ 15 m in production -
tls_probe_last_run_timestamp_secondsalerted on, so a dead exporter is not silence - Alert route tested by stopping Caddy and watching
TLSProbeFailingarrive - Unit enabled, so the exporter comes back after a reboot (
enable, not juststart)
Common problems
| Symptom | Cause | Fix |
|---|---|---|
failed to bind host port ... address already in use |
Host port 8443 taken by another service | Map a different host port: -p 127.0.0.1:18443:8443 |
tls_cert_verify_ok 0 on an internal host |
Private CA not in the system store | Add ca_file for that target, or accept the info alert |
tls_probe_error{error="self-signed certificate"} 1 |
Certificate is self-signed | Expected; expiry is still reported. Pin the CA if you want verify_ok 1 |
days_remaining 0 and a permanent critical alert |
Short-lived certificate (Caddy internal CA = 12 h) | Alert on seconds or on fraction of lifetime, not on days |
up{job="tls"} 0 inside the container |
host.docker.internal unresolved on Linux |
Add extra_hosts: ["host.docker.internal:host-gateway"] |
tls_probe_success 0 with FileNotFoundError: openssl |
Host without the CLI | apt install openssl; the exporter shells out to it |
Empty subject label on a Caddy certificate |
Caddy leaves the subject CN empty, identity is SAN-only | Filter on issuer, not subject |
| Scrape latency spikes | Every scrape triggers a full handshake | Raise PROBE_INTERVAL, scrape less often than you probe |