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_exporter opens a TLS connection to each target, reads the leaf certificate, and publishes tls_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 notAfter off 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
  • openssl CLI on the host (verified 3.0.2; shipped by the openssl package 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 8443 inside the container. If 8443 is already taken on the host, map another host port (18443:8443) — otherwise docker run fails with failed 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" }
  ]
}
  • servername is the SNI name and the hostname checked during verification. Use it whenever you connect to 127.0.0.1 — otherwise you validate the certificate against localhost and get a spurious mismatch.
  • ca_file pins 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 file target 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.json covers every hostname you serve, plus every private CA root you depend on
  • servername set explicitly for any target reached by IP
  • ca_file pinned for private CAs; verify_ok 0 treated as a real alert, not noise
  • Exporter binds 0.0.0.0:9812 but 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_seconds alerted on, so a dead exporter is not silence
  • Alert route tested by stopping Caddy and watching TLSProbeFailing arrive
  • Unit enabled, so the exporter comes back after a reboot (enable, not just start)

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

Related guides