A tested checker run against real TLS servers in every bad state: expired, expiring, wrong name, self-signed, missing intermediate, renewed-but-not-reloaded; with SNI, protocol and scheduling
TLS certificate monitoring: expiry, chains, names, and renewals that really worked
./check-tls.sh www.example.com --warn 30 --crit 14
Certificates still expire in production, even at companies with automation, and the outage is always embarrassing: a browser warning in front of customers, an API that suddenly rejects every call, a mail server that other servers stop trusting. Automation reduces the chance, but it adds its own failure modes: the renewal job that stopped months ago, the certificate renewed on disk but never loaded by the server, the new server nobody put under automation, the chain that works in one browser and not in another. This page is about checking what clients REALLY see, from outside, on a schedule, and about turning that into alerts people can act on.
The TLS Certificates page and the OpenSSL page explain how to create, convert and inspect certificates. This page assumes you have them, and answers a different question: how do I know, every day, that they are still fine? Everything was tested for real. The lab built its own certificate authority with an intermediate CA and issued certificates in every state you want to detect (valid, expiring in 5 days, expired, wrong name, self-signed, missing intermediate, name only in the CN), then ran real TLS servers for each on local ports with openssl s_server and checked them with openssl, curl and a checker script that is listed in full below. Nothing touched the Internet, and no real certificate was used. Not tested here, and marked "not run": certbot and other ACME clients (none installed on the lab machine), STARTTLS against real mail servers, OCSP stapling, Certificate Transparency monitoring, and scanners such as testssl.sh and sslscan (not installed). OpenSSL 3.0.13 on Ubuntu 24.04.
Why certificates still expire
The renewal job stopped working: the timer was never enabled on a rebuilt server, the ACME challenge is blocked by a new firewall rule or a redirect, a DNS API token expired, the disk is full, the account hit a rate limit
The certificate was renewed on disk, but the server never loaded it: nginx, Apache, HAProxy, Postfix and Dovecot read the files at start or reload, not continuously. This is the most common real cause (section 9)
A new host, load balancer or service was never added to the automation (a certificate bought by hand, once, with a one-year date, by someone who left)
The wrong certificate is served for a name: the default server block answers a name you did not configure, or a CDN or load balancer has its own copy
The chain is incomplete: the server sends its own certificate without the intermediate, which works in a browser that has cached it and fails in curl, in Java, in monitoring agents and in mobile apps (section 5)
The name is not in the certificate: a new hostname added to DNS, a wildcard that does not cover two levels, a certificate with only a CN
Lifetimes keep shrinking. Public certificates used to last years; the maximum has been falling (the CA/Browser Forum has voted for steps down to about 47 days by 2029; check the current rules before you plan, this was not verified here). Short lifetimes make automation mandatory and make a silent failure fatal within weeks, not years
Nobody owns it: no one is paged, and the reminder email goes to a mailbox that does not exist any more. Do not rely on the CA's reminder emails (they depend on a contact address that you control and keep up to date)
The six things worth checking
A good check connects the way a client does and tests all of these. Each one is tested below:
1. Days left: the notAfter date of the certificate that is actually presented, with thresholds (30 and 14 days is a common warning and critical pair; 7 and 3 for a daily job on short-lived certificates)
2. The chain: the server sends every certificate a client needs, verified against ONLY the root store, not against intermediates you happen to have installed
3. The name: the name clients use is in the subjectAltName (not just the CN)
4. The certificate on the wire equals the certificate on disk (the "renewed but not reloaded" check)
5. It answers at all, quickly: a timeout is a finding too
6. Optionally the protocol and cipher: no ancient versions, a minimum of TLS 1.2
And one rule that cuts across all of them: test the LIVE endpoint over the network, with the right name. A check of the certificate FILE (openssl x509 -checkend on /etc/ssl/...) says nothing about what the server is serving.
The lab: a CA and servers in every state
A root CA and an intermediate CA, both P-256 elliptic-curve keys, and leaf certificates issued by the intermediate. To make an EXPIRED certificate you must issue one with a start date in the past, which "openssl x509 -req" in OpenSSL 3.0 cannot do; "openssl ca" can, with -startdate and -enddate. The helper used for every leaf (shortened):
$ openssl ca -batch -config int.cnf -cert int/cert.pem -keyfile int/key.pem -in good.csr -out good.crt -startdate 20260902... -enddate 20261201... -extensions v3_leaf -notext
The certificates and the real servers (each server started with openssl s_server -accept PORT -cert ... -key ... -www -quiet, in the background, and stopped at the end):
port 18441 good.test valid 60 days, full chain sent (leaf + intermediate)
port 18442 soon.test expires in 5 days
port 18443 expired.test expired 30 days ago
port 18444 other.test a certificate for ANOTHER name
port 18445 self.test self-signed
port 18446 good.test valid, but the server sends ONLY the leaf (intermediate missing)
port 18447 good.test TLS 1.2 only
port 18448 good.test and other.test on one port, selected by SNI
port 18451 cnonly.test name only in the CN, no subjectAltName
The names (.test) are reserved and never resolve; the checks connect to 127.0.0.1 and pass the name with -servername, exactly as you would to check a real host through a specific address.
What a client sees: openssl s_client, x509 and verify
Inspect the certificate itself (the validity dates and the names):
$ for n in good soon expired; do printf '%-8s ' $n; openssl x509 -in certs/$n.crt -noout -enddate -subject | tr '\n' ' '; echo; done
good notAfter=Dec 1 22:50:20 2026 GMT subject=CN = good.test
soon notAfter=Oct 7 22:50:20 2026 GMT subject=CN = soon.test
expired notAfter=Sep 2 22:50:20 2026 GMT subject=CN = expired.test
$ openssl x509 -in certs/good.crt -noout -ext subjectAltName,keyUsage,extendedKeyUsage
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage:
TLS Web Server Authentication
X509v3 Subject Alternative Name:
DNS:good.test
(shown condensed: the lab output had the same lines.) -checkend N asks "will the certificate still be valid N seconds from now?" and answers with the exit status (0 yes, 1 no), which makes it a one-line test for scripts:
$ for n in good soon expired; do for days in 7 30; do openssl x509 -in certs/$n.crt -noout -checkend $((days*86400)) >/dev/null; printf '%-8s valid in %2d days? exit %d\n' $n $days $?; done; done
good valid in 7 days? exit 0
good valid in 30 days? exit 0
soon valid in 7 days? exit 1
soon valid in 30 days? exit 1
expired valid in 7 days? exit 1
expired valid in 30 days? exit 1
The good certificate is fine for both horizons; the one with 5 days left fails both; the expired one fails both. openssl verify checks a certificate against a trust store and an intermediate, and -attime asks the same question at ANOTHER time, so you can find out today that a certificate will be invalid on the day of the planned change:
$ openssl verify -CAfile ca/cert.pem -untrusted int/cert.pem certs/good.crt
certs/good.crt: OK
$ openssl verify -CAfile ca/cert.pem -untrusted int/cert.pem certs/expired.crt
error 10 at 0 depth lookup: certificate has expired
$ openssl verify -attime $(date -d '+70 days' +%s) -CAfile ca/cert.pem -untrusted int/cert.pem certs/good.crt
error 10 at 0 depth lookup: certificate has expired
In 70 days the good certificate (60 days of validity left) will have expired too. The error numbers are OpenSSL's verification codes and appear in s_client and in many monitoring tools: 10 is expired, 18 self-signed, 20 "unable to get local issuer certificate" (the issuer is not known), 21 "unable to verify the first certificate" (the chain is incomplete), 62 hostname mismatch. Connecting to a real server and printing the useful lines:
$ echo | openssl s_client -connect 127.0.0.1:18441 -servername good.test -CAfile certs/ca-bundle.pem -verify_hostname good.test 2>&1 | grep -E 'depth=|Verification|Verify return|Protocol|Cipher is|Server public key|Peer signing|subject=|issuer=|^ [0-9] s:'
depth=2 CN = Lab Root CA
depth=1 CN = Lab Intermediate CA
depth=0 CN = good.test
0 s:CN = good.test
1 s:CN = Lab Intermediate CA
subject=CN = good.test
issuer=CN = Lab Intermediate CA
Peer signing digest: SHA256
Verification: OK
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 256 bit
Verify return code: 0 (ok)
The echo gives s_client an empty input so it exits after the handshake. The options that matter for monitoring: -servername NAME (SNI: see section 6), -verify_hostname NAME (check that the certificate matches the name), -CAfile (the trust store: see section 5), and -showcerts (print every certificate the server sent). The same connection to the other servers, with the verification line each produced:
$ for t in "18443 expired.test" "18444 good.test" "18445 self.test"; do ...; done
18443 expired.test Verification error: certificate has expired (code 10)
18444 good.test Verification error: hostname mismatch (code 62)
18445 self.test Verification error: self-signed certificate (code 18)
The server on 18444 presents a certificate for other.test when asked for good.test: the classic "wrong certificate behind a name" outage.
The incomplete-chain trap
A server must send its own certificate AND every intermediate between it and a root that clients trust. A server that sends only its own certificate works for any client that already has the intermediate (a browser that visited another site of the same CA, a machine with a full CA bundle) and fails for clients that do not, and the failure shows up in the one place nobody tests from. The lab's first check of the leaf-only server said everything was fine, and the reason is the lesson:
$ for p in 18441 18446; do printf 'port %s: ' $p; echo | openssl s_client -connect 127.0.0.1:$p -servername good.test -CAfile certs/ca-bundle.pem ... | grep -E '^Verification'; done
port 18441: Verification: OK
port 18446: Verification: OK
Both OK, although port 18446 sends only ONE certificate. The test trust bundle (ca-bundle.pem) contained the intermediate as well as the root, so OpenSSL could complete the chain from its own store. The correct test uses the ROOT only (or the system store, which contains roots only), so the server must supply the intermediate itself:
$ for p in 18441 18446; do ...; echo | openssl s_client -connect 127.0.0.1:$p -servername good.test -CAfile ca/cert.pem -verify_hostname good.test | grep -E '^Verification|Verify return code'; done
port 18441 (fullchain): Verification: OK, Verify return code: 0 (ok)
port 18446 (leaf-only): Verification error: unable to verify the first certificate, Verify return code: 21
$ echo | openssl s_client -connect 127.0.0.1:18446 -servername good.test -CAfile ca/cert.pem 2>&1 | grep -E '^depth|^verify error'
depth=0 CN = good.test
verify error:num=20:unable to get local issuer certificate
depth=0 CN = good.test
verify error:num=21:unable to verify the first certificate
$ curl -sS -o /dev/null --cacert ca/cert.pem https://good.test:18446/ --resolve good.test:18446:127.0.0.1
curl: (60) SSL certificate problem: unable to get local issuer certificate
$ curl -sS -o /dev/null -w 'fullchain server via curl: HTTP %{http_code}, ssl_verify_result=%{ssl_verify_result}\n' --cacert ca/cert.pem https://good.test:18441/ --resolve good.test:18441:127.0.0.1
fullchain server via curl: HTTP 200, ssl_verify_result=0
With the root only, the leaf-only server fails the way a strict client fails, with codes 20 and 21 and curl's error 60, while the full-chain server verifies and answers HTTP 200. The number of certificates sent is a quick signal too (s_client -showcerts | grep -c 'BEGIN CERTIFICATE': 2 for the full chain, 1 for the leaf-only server). The fix on the server is to install the FULL chain: in nginx ssl_certificate points to fullchain.pem (the leaf first, then the intermediates), in Apache SSLCertificateFile with the chain in the same file or SSLCertificateChainFile in older versions; with Let's Encrypt it is the file fullchain.pem, not cert.pem. Never add the root to the chain (it is useless) and put the certificates in order: your certificate first.
SNI: the certificate depends on the name you ask for
One IP address and port can serve many certificates; the client says which name it wants in the handshake (Server Name Indication, SNI), and the server picks the certificate. A check that connects without the right name tests the DEFAULT certificate, not the one your visitors get. One server on port 18448 had two certificates:
$ for sni in good.test other.test unknown.test; do printf 'SNI=%-13s -> certificate subject: ' $sni; echo | openssl s_client -connect 127.0.0.1:18448 -servername $sni 2>/dev/null | grep -E '^subject=' | head -1; done
SNI=good.test -> certificate subject: subject=CN = good.test
SNI=other.test -> certificate subject: subject=CN = other.test
SNI=unknown.test -> certificate subject: subject=CN = good.test
$ printf 'no SNI at all -> '; echo | openssl s_client -connect 127.0.0.1:18448 -noservername 2>/dev/null | grep -E '^subject=' | head -1
no SNI at all -> subject=CN = good.test
A name the server does not know, and no name at all, both get the default (good.test): so a monitor that connects to the IP address without -servername sees a perfectly valid certificate for the wrong name, and never learns that other.test is expired. Always check HOST:PORT with the name, and when you check through a specific address (a load balancer node, a server behind DNS), pass the address to connect to and the name to -servername separately, as the checker below does (HOST is the address, --sni the name). The same applies to curl: use --resolve NAME:PORT:IP, as above. Also check every name you serve, not only the main one.
Protocol versions
Which TLS versions the server accepts matters for compliance and for old clients. The server on 18447 was started TLS 1.2 only; the client was forced to each version:
$ for v in tls1_3 tls1_2 tls1_1; do printf '%-7s on the TLS1.2-only server: ' $v; echo | openssl s_client -connect 127.0.0.1:18447 -servername good.test -$v 2>&1 | grep -E 'Protocol *:|alert|no protocols|Cipher is|error' | head -1 | cut -c1-90; done
tls1_3 on the TLS1.2-only server: error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version
tls1_2 on the TLS1.2-only server: verify error:num=20:unable to get local issuer certificate
tls1_1 on the TLS1.2-only server: error:0A0000BF:SSL routines:tls_setup_handshake:no protocols available
$ echo | openssl s_client -connect 127.0.0.1:18447 -servername good.test | grep -E 'Protocol *:|New,' | head -1
New, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384
Reading the three lines: asking for TLS 1.3 got a protocol-version alert from the server (it does not offer it); asking for TLS 1.2 worked (the "verify error" is only about the trust store of that command, which had no CA file) ; and asking for TLS 1.1 failed on the CLIENT side ("no protocols available"): this OpenSSL 3 build refuses to speak TLS 1.1 at its default security level, so s_client cannot be used to prove that a server REJECTS old protocols. For that, use a scanner built for it (testssl.sh, sslscan or nmap with ssl-enum-ciphers: none was installed here, so not run) or lower the client's security level on purpose, in a test environment only. A plain connection negotiated the best common version (TLS 1.2 here) and the cipher, which the checker prints.
The checker script
This is the checker (47 lines), exactly as tested. It connects like a client, verifies, extracts the days left, compares with a file if you give one, and ends with a Nagios-style exit status (0 OK, 1 WARNING, 2 CRITICAL, 3 UNKNOWN) and one line of text, or a JSON object with --json. It needs openssl, date, sed, grep, timeout and jq (only for --json).
$ cat check-tls.sh
#!/usr/bin/env bash
# check-tls.sh: check the TLS certificate a server really presents
# usage: check-tls.sh HOST[:PORT] [--sni NAME] [--ca FILE] [--warn DAYS] [--crit DAYS] [--file PEM] [--json]
# exit: 0 OK, 1 WARNING, 2 CRITICAL, 3 UNKNOWN (Nagios convention)
set -uo pipefail
target=${1:?usage: check-tls.sh HOST[:PORT] [options]}; shift
host=${target%%:*}; port=${target##*:}; [[ $port == "$target" ]] && port=443
sni=$host ca="" warn=30 crit=14 file="" json=0 tmo=10
while (( $# )); do
case $1 in
--sni) sni=$2; shift 2 ;; --ca) ca=$2; shift 2 ;; --warn) warn=$2; shift 2 ;;
--crit) crit=$2; shift 2 ;; --file) file=$2; shift 2 ;; --json) json=1; shift ;;
--timeout) tmo=$2; shift 2 ;; *) echo "UNKNOWN - bad option $1"; exit 3 ;;
esac
done
finish() { # finish STATUS MESSAGE [extra jq args]
local status=$1 msg=$2 code=3
case $status in OK) code=0 ;; WARNING) code=1 ;; CRITICAL) code=2 ;; esac
if (( json )); then
jq -n --arg host "$host" --arg sni "$sni" --arg status "$status" --arg msg "$msg" \
--arg days "${days:-}" --arg end "${end_iso:-}" --arg proto "${proto:-}" --arg verify "${vtext:-}" \
'{host:$host, sni:$sni, status:$status, message:$msg, days_left:($days|tonumber? // null), not_after:$end, protocol:$proto, verify:$verify}'
else echo "$status - $host:$port ($sni): $msg"; fi
exit "$code"
}
cafile=(); [[ -n $ca ]] && cafile=(-CAfile "$ca")
out=$(timeout "$tmo" openssl s_client -connect "$host:$port" -servername "$sni" -verify_hostname "$sni" "${cafile[@]}" -showcerts </dev/null 2>&1); rc=$?
(( rc == 124 )) && finish CRITICAL "connection timed out after ${tmo}s"
pem=$(sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/{p;/-----END CERTIFICATE-----/q}' <<<"$out")
[[ -n $pem ]] || finish CRITICAL "no certificate received (connection refused, or not a TLS port): $(grep -m1 -E 'errno|error|refused' <<<"$out" | cut -c1-80)"
vcode=$(grep -m1 -oE 'Verify return code: [0-9]+' <<<"$out" | grep -oE '[0-9]+$'); vtext=$(grep -m1 -oE 'Verify return code: [0-9]+ \(.*\)' <<<"$out" | sed 's/.*(\(.*\))/\1/')
proto=$(grep -m1 -oE 'Protocol *: *\S+|New, \S+' <<<"$out" | grep -oE 'TLSv[0-9.]+|SSLv[0-9]' | head -1)
nchain=$(grep -c 'BEGIN CERTIFICATE' <<<"$out")
end=$(openssl x509 -noout -enddate <<<"$pem" | cut -d= -f2); end_epoch=$(date -d "$end" +%s); end_iso=$(date -u -d "@$end_epoch" +%F)
days=$(( (end_epoch - $(date +%s)) / 86400 )); (( end_epoch < $(date +%s) )) && days=$(( -(( $(date +%s) - end_epoch ) / 86400) ))
sub=$(openssl x509 -noout -subject <<<"$pem" | sed 's/^subject= *//')
if [[ -n $file ]]; then
served=$(openssl x509 -noout -fingerprint -sha256 <<<"$pem"); ondisk=$(openssl x509 -noout -fingerprint -sha256 -in "$file")
[[ $served == "$ondisk" ]] || finish CRITICAL "the server presents a different certificate from $file (renewed but not reloaded?); serving one that expires $end_iso"
fi
san=$(openssl x509 -noout -ext subjectAltName <<<"$pem" 2>/dev/null | tail -n +2)
[[ -n $san ]] || finish CRITICAL "no subjectAltName in the certificate (browsers and many libraries reject a name that is only in the CN), expires $end_iso"
if [[ ${vcode:-1} != 0 ]]; then finish CRITICAL "verification failed: $vtext (code $vcode), expires $end_iso, $nchain certificate(s) sent"; fi
(( days < 0 )) && finish CRITICAL "EXPIRED $(( -days )) days ago ($end_iso)"
(( days < crit )) && finish CRITICAL "expires in $days days ($end_iso), limit $crit"
(( days < warn )) && finish WARNING "expires in $days days ($end_iso), limit $warn"
finish OK "$days days left (expires $end_iso), $proto, $nchain certificate(s) sent, chain verified"
The design choices, in order: it passes -servername and -verify_hostname so the name is tested, and it takes --ca so that you can give it the ROOT store of a private CA (without --ca it uses OpenSSL's default store: on Debian and Ubuntu the system CA bundle). It never uses -verify_return_error, because that would stop the handshake before the certificate could be read, and then the check could not say WHICH certificate failed. It extracts the first certificate from the output of -showcerts, which is the server's own. The days left are computed from notAfter with date -d (so it needs GNU date; on macOS use gdate). The checks run in order of severity: a connection that times out or gives no certificate is critical; if --file is given, the presented certificate must have the same SHA-256 fingerprint as the file; a certificate without a subjectAltName is critical (the next paragraph); a failed verification is critical with the reason; then expiry. The result for each state in the lab, with the default thresholds (warn 30 days, critical 14), run as ./check-tls.sh 127.0.0.1:PORT --sni NAME --ca ca/cert.pem:
OK - 127.0.0.1:18441 (good.test): 59 days left (expires 2026-12-01), TLSv1.3, 2 certificate(s) sent, chain verified
[exit 0]
CRITICAL - 127.0.0.1:18442 (soon.test): expires in 4 days (2026-10-07), limit 14
[exit 2]
WARNING - 127.0.0.1:18442 (soon.test): expires in 4 days (2026-10-07), limit 7 (with --warn 7 --crit 3)
[exit 1]
CRITICAL - 127.0.0.1:18443 (expired.test): verification failed: certificate has expired (code 10), expires 2026-09-02, 2 certificate(s) sent
[exit 2]
CRITICAL - 127.0.0.1:18444 (good.test): verification failed: hostname mismatch (code 62), expires 2026-12-21, 2 certificate(s) sent
[exit 2]
CRITICAL - 127.0.0.1:18445 (self.test): verification failed: self-signed certificate (code 18), expires 2027-10-02, 1 certificate(s) sent
[exit 2]
CRITICAL - 127.0.0.1:18446 (good.test): verification failed: unable to verify the first certificate (code 21), expires 2026-12-01, 1 certificate(s) sent
[exit 2]
CRITICAL - 127.0.0.1:18449 (good.test): no certificate received (connection refused, or not a TLS port): ...Connection refused:..
[exit 2]
The eight lines are: healthy; five days left (note "4 days": the number is rounded DOWN, so the check is slightly conservative); the same certificate with tighter thresholds (a warning, since 4 is below 7 but above 3); expired; wrong name; self-signed (note it sent one certificate); the missing intermediate (also one certificate sent); and a port where nothing listens. With --json the same OK result is:
$ ./check-tls.sh 127.0.0.1:18441 --sni good.test --ca ca/cert.pem --json
{
"host": "127.0.0.1",
"sni": "good.test",
"status": "OK",
"message": "59 days left (expires 2026-12-01), TLSv1.3, 2 certificate(s) sent, chain verified",
"days_left": 59,
"not_after": "2026-12-01",
"protocol": "TLSv1.3",
"verify": "ok"
}
The subjectAltName check was added after a test showed a gap. A certificate whose name is only in the CN (the subject), with no subjectAltName extension, was ACCEPTED by both openssl s_client -verify_hostname and curl:
$ openssl x509 -in certs/cnonly.crt -noout -ext subjectAltName
No extensions in certificate
$ echo | openssl s_client -connect 127.0.0.1:18451 -servername cnonly.test -CAfile certs/cnonly.crt -verify_hostname cnonly.test | grep -E '^Verification|Verify return code'
Verification: OK
Verify return code: 0 (ok)
$ curl -sS -o /dev/null -w 'HTTP %{http_code}\n' --cacert certs/cnonly.crt https://cnonly.test:18451/ --resolve cnonly.test:18451:127.0.0.1
HTTP 200
Both command-line tools were satisfied by the CN alone, while browsers and many libraries require the name in the subjectAltName (the CN fallback was deprecated years ago; this was not tested with a browser in the lab, so verify with yours). A monitor built on those tools would report a certificate as healthy that real users cannot use. The checker now insists on a subjectAltName:
$ ./check-tls.sh 127.0.0.1:18451 --sni cnonly.test --ca certs/cnonly.crt
CRITICAL - 127.0.0.1:18451 (cnonly.test): no subjectAltName in the certificate (browsers and many libraries reject a name that is only in the CN), expires 2026-11-01
[exit 2]
(It checks that the extension exists. It does not check that your name is listed in it: -verify_hostname does that, as the hostname-mismatch line above shows.) Remember that wildcards cover one level only (*.example.com matches www.example.com, not a.b.example.com and not example.com itself), and that the name must be listed in the certificate: a certificate for www.example.com does not cover example.com unless both are in the SAN list.
Renewed on disk, never reloaded
The most common real incident: the renewal job works, the files on disk are new, the server keeps presenting the old certificate until it is reloaded, and it expires on schedule. The lab reproduced it exactly: a server started with a certificate expiring in 5 days (live/site.crt), then the file replaced by a renewed 90-day certificate while the server kept running. The checker is given --file so it can compare:
$ ./check-tls.sh 127.0.0.1:18450 --sni soon.test --ca ca/cert.pem --file live/site.crt --warn 3 --crit 1
OK - 127.0.0.1:18450 (soon.test): 4 days left (expires 2026-10-07), TLSv1.3, 2 certificate(s) sent, chain verified
[exit 0]
(Day 0: the file and the live certificate match, and there are 4 days left, above the thresholds of 3 and 1.) Now the renewal job replaces the file:
$ cp live/new.crt live/site.crt; openssl x509 -in live/site.crt -noout -enddate
notAfter=Dec 31 22:51:35 2026 GMT
$ openssl x509 -in live/site.crt -noout -checkend $((30*86400)) && echo "file: valid for at least 30 more days -> looks healthy"
Certificate will not expire
file: valid for at least 30 more days -> looks healthy
A check of the file, which is what a renewal script, a configuration-management run and "certbot certificates" look at, says all is well. The live endpoint tells another story:
$ ./check-tls.sh 127.0.0.1:18450 --sni soon.test --ca ca/cert.pem --file live/site.crt --warn 3 --crit 1
CRITICAL - 127.0.0.1:18450 (soon.test): the server presents a different certificate from live/site.crt (renewed but not reloaded?); serving one that expires 2026-10-07
[exit 2]
CRITICAL, with the likely reason in the message, four days before the outage instead of on the day. After the server was restarted the same check passed:
$ ./check-tls.sh 127.0.0.1:18450 --sni soon.test --ca ca/cert.pem --file live/site.crt
OK - 127.0.0.1:18450 (soon.test): 89 days left (expires 2026-12-31), TLSv1.3, 2 certificate(s) sent, chain verified
[exit 0]
The permanent fix is a deploy hook that reloads the service whenever a certificate is renewed (not run here; certbot is not installed): certbot renew --deploy-hook "systemctl reload nginx", or in /etc/letsencrypt/renewal-hooks/deploy/ a small script; acme.sh and other ACME clients have an equivalent install command (--reloadcmd). Reload, not restart, where the server supports it: nginx -s reload, systemctl reload apache2, postfix reload. Two points: a hook runs only when a certificate was actually renewed (test with a forced renewal on a spare host), and the monitoring check with --file is the safety net for the day the hook silently fails. If the file you compare is not the one the server is configured to use, the check lies: compare the file named in the server's own configuration.
Timeouts, flapping and checking many hosts
A host that does not answer must not hang the check. The checker wraps the connection in timeout and reports it:
$ ./check-tls.sh 192.0.2.1:443 --timeout 3 --ca ca/cert.pem; echo "[exit $?] after 3 s"
CRITICAL - 192.0.2.1:443 (192.0.2.1): connection timed out after 3s
[exit 2]
(192.0.2.1 is a documentation address, so the packets go nowhere: a realistic firewall drop.) A single failed connection is not always a real problem: a restart, a load balancer health check or a brief network glitch gives a one-off CRITICAL. In your monitoring system require two or three consecutive failures (or re-run the check once after a pause) before paging, and treat certificate EXPIRY separately: it is certain and slow, so it deserves its own thresholds and no flapping logic.
To check many hosts, a small runner reads a list of "HOST[:PORT] [SNI]" lines, runs the checker for each and prints a table with the worst first, and exits with the worst status:
$ cat check-tls-all.sh
#!/usr/bin/env bash
# check-tls-all.sh HOSTFILE [check-tls.sh options]: check every line "HOST[:PORT] [SNI]"; print a table sorted by status; exit with the worst
set -uo pipefail
list=${1:?usage: check-tls-all.sh HOSTFILE [options]}; shift
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd); worst=0; rows=()
while read -r target sni _; do
[[ -z $target || $target == \#* ]] && continue
extra=(); [[ -n ${sni:-} ]] && extra=(--sni "$sni")
line=$("$here/check-tls.sh" "$target" "${extra[@]}" "$@"); rc=$?
(( rc > worst )) && worst=$rc
rows+=("$rc $line")
done < "$list"
printf '%s\n' "${rows[@]}" | sort -rn | cut -d' ' -f2-
n=${#rows[@]}; printf -- '--- %d checked, worst status %s\n' "$n" "$(case $worst in 0) echo OK;; 1) echo WARNING;; 2) echo CRITICAL;; *) echo UNKNOWN;; esac)"
exit "$worst"
$ cat hosts.txt
# host:port sni
127.0.0.1:18441 good.test
127.0.0.1:18442 soon.test
127.0.0.1:18443 expired.test
127.0.0.1:18446 good.test
127.0.0.1:18450 soon.test
$ ./check-tls-all.sh hosts.txt --ca ca/cert.pem
CRITICAL - 127.0.0.1:18446 (good.test): verification failed: unable to verify the first certificate (code 21), expires 2026-12-01, 1 certificate(s) sent
CRITICAL - 127.0.0.1:18443 (expired.test): verification failed: certificate has expired (code 10), expires 2026-09-02, 2 certificate(s) sent
CRITICAL - 127.0.0.1:18442 (soon.test): expires in 4 days (2026-10-07), limit 14
OK - 127.0.0.1:18450 (soon.test): 89 days left (expires 2026-12-31), TLSv1.3, 2 certificate(s) sent, chain verified
OK - 127.0.0.1:18441 (good.test): 59 days left (expires 2026-12-01), TLSv1.3, 2 certificate(s) sent, chain verified
--- 5 checked, worst status CRITICAL
[exit 2]
Three problems at the top, two healthy ones at the bottom, and one number to alert on. (The runner sorts by exit code, so a warning sits between the critical and OK lines.) Where does the host list come from? Do not type it by hand and let it rot: generate it from what is deployed. Candidates: the server_name lines of your nginx or Apache configuration (grep -rh server_name /etc/nginx/ and similar), DNS zone exports, the load balancer or CDN inventory, the cloud provider's certificate manager, and your ACME client's list of managed names. Reconcile the list with reality regularly: a certificate that exists but is on no list is the next outage. Services that are not websites have certificates too: mail (SMTP submission, IMAPS), LDAPS, database listeners, VPN, internal APIs, and anything behind a private CA (pass --ca with that CA's root). For protocols that start in plain text and upgrade, s_client has -starttls: for example openssl s_client -connect mail.example.com:587 -starttls smtp -servername mail.example.com (not run here: the lab has no mail server to test against; the checker would need that option added).
Scheduling and alerting
Run the check from somewhere OUTSIDE the servers it checks (another machine, another network, ideally another provider), so that it sees what the Internet sees, including firewalls and load balancers, and so that an outage of the server does not silence the check. Once a day is enough for expiry; every few minutes if you also use it as an uptime check. The job runner from the Scheduling and Monitoring Scripts page gives the check a lock, a timeout, logging, a status file and an alert; the lab ran the multi-host check through it:
$ run-job.sh tls-check --state st --timeout 60 -- ./check-tls-all.sh hosts.txt --ca ca/cert.pem
tls-check: START: ./check-tls-all.sh hosts.txt --ca ca/cert.pem
tls-check: | CRITICAL - 127.0.0.1:18446 (good.test): verification failed: unable to verify the first certificate (code 21), expires 2026-12-01, 1 ce
tls-check: | CRITICAL - 127.0.0.1:18443 (expired.test): verification failed: certificate has expired (code 10), expires 2026-09-02, 2 certificate(s)
tls-check: | CRITICAL - 127.0.0.1:18442 (soon.test): expires in 4 days (2026-10-07), limit 14
tls-check: | OK - 127.0.0.1:18450 (soon.test): 89 days left (expires 2026-12-31), TLSv1.3, 2 certificate(s) sent, chain verified
tls-check: | OK - 127.0.0.1:18441 (good.test): 59 days left (expires 2026-12-01), TLSv1.3, 2 certificate(s) sent, chain verified
tls-check: | --- 5 checked, worst status CRITICAL
tls-check: FAIL: failure (exit 2) after 1s
[exit 2]
$ jq -c '{job,result,exit,duration_s}' st/tls-check.status.json
{"job":"tls-check","result":"failure","exit":2,"duration_s":1}
The runner treats the checker's non-zero exit as a failure of the job, logs the report lines to the journal under the tag job-tls-check and (with --alert-url) sends the message to a webhook with the rate limiting described on that page. Together with a heartbeat or staleness check on the runner itself, you are protected from both a failing certificate and a checker that stopped running. Practical alerting rules for certificates:
Escalate with time: a ticket at 30 days, a message to the team at 14, a page at 7 or below, and a page for any verification failure (expired, wrong name, broken chain) immediately
One alert per certificate, with the name, the days left, the reason and the next step (which server, which renewal job, which runbook), and a recovery message when it is fixed
Alert on the CHECKER going silent as well (see the staleness check on the scheduling page)
Keep a list of certificate owners; a certificate without an owner is an outage with a date
Also watch Certificate Transparency logs for certificates issued for your domains that you did not request: services such as crt.sh publish them, and several monitoring tools alert on new ones (not run here). It is the way to find a forgotten certificate and a mis-issued one
Pair expiry monitoring with a yearly drill: renew one certificate by hand, with automation off, to prove the procedure still works and the people remember it
Checking the renewal automation itself
Monitoring the endpoint catches the symptom; these checks catch the cause earlier. All of this needs a real ACME client and was NOT run (none is installed on the lab machine); the commands are the standard ones for certbot, the most common client:
systemctl list-timers | grep certbot the renewal timer exists and has a next run (on Debian and Ubuntu the package installs a timer, and a cron entry in /etc/cron.d/certbot as well)
sudo certbot certificates every certificate certbot manages, with its names, expiry date and path (and "INVALID: EXPIRED" where it is)
sudo certbot renew --dry-run a full simulated renewal against the CA's staging environment: it proves the challenge works (port 80 open, the webroot or DNS credentials valid) without issuing anything
sudo journalctl -u certbot --since "30 days ago" the renewal log (or /var/log/letsencrypt/letsencrypt.log)
ls -l /etc/letsencrypt/renewal-hooks/deploy/ the hook that reloads the service
The usual causes when renewal fails: a challenge blocked by a firewall, a redirect or a changed document root (HTTP-01), an expired or restricted API token (DNS-01), a changed DNS name, the ACME client's own package not updated after the CA changed its chain, a full disk, a rate limit after repeated failures. Read the error in the log; do not retry in a loop, because failed attempts count towards rate limits
Keep renewals frequent: renew when a third of the life is left (the default for 90-day certificates is 30 days before expiry), so there are many retries before an outage; the shorter the lifetime, the more this matters
Treat these checks as complements, not substitutes: the dry run proves the renewal COULD work, the endpoint check proves it DID.
Common problems
The check says OK but users get warnings the check uses a trust store with intermediates (verify against the root only, section 5), connects without SNI (pass -servername), or reaches a different server than users (CDN, load balancer, IPv6 versus IPv4: check both)
Works in the browser, fails in curl, Java or a monitoring agent an incomplete chain: the server sends no intermediate and the browser had it cached; install fullchain.pem and test with the root-only store
Certificate for the wrong name the default server block answered, or SNI was missing: check with -servername and list every name; look for a catch-all server that serves the wrong certificate
"certificate has expired" on a certificate that was renewed the server still presents the old one: reload it; check with --file; add a deploy hook
hostname mismatch although the name is "in the certificate" the name is only in the CN, a wildcard does not cover that many levels, or the bare domain is missing from the SAN list: read openssl x509 -noout -ext subjectAltName
unable to get local issuer certificate (20) / unable to verify the first certificate (21) the intermediate is missing from what the server sends (see section 5), or a private CA root is missing from the client's trust store (use --ca / install the root)
self-signed certificate (18) on a public host the default or a temporary certificate is served (a new server before its first issuance, a misconfigured virtual host)
Connection times out only from the monitor a firewall or an IP allow-list blocks the monitoring address; a rate limit or a fail2ban ban on the monitor (whitelist it)
s_client prints "no protocols available" for TLS 1.1/1.0 the local OpenSSL refuses old protocols: use testssl.sh, sslscan or nmap, or a client with a lowered security level in a lab
Days-left numbers are off by a day the check rounds down: the example "4 days" for a certificate with 4.x days left; times are UTC in notAfter
date: invalid date on macOS or BusyBox the script uses GNU date -d: install coreutils (gdate) or port the date arithmetic
Everything fails right after a CA changes its chain the server still sends an old intermediate: get the current chain from the CA and update fullchain.pem; also update the ACME client
A certificate was issued that nobody requested look at the Certificate Transparency logs for your domains; revoke it; find out how it was validated (DNS, e-mail, a compromised account)
Tips
Check the live endpoint, from outside, with the real name (-servername), and verify against the ROOT store only. Anything else can say OK while users see a warning.
Compare what the server presents with what is on disk (--file): it catches "renewed but not reloaded", the most common cause of an expired-certificate outage that had automation.
Count the days and the certificates: 30 and 14 days for warning and critical, a page for any verification failure, and look at how many certificates the server sends (1 can be wrong).
Require the name in the subjectAltName: the common tools accept a CN-only certificate, browsers do not.
Add a deploy hook that reloads the service on renewal, and an alert for the day it does not.
Monitor every name, every port and every protocol that has a certificate (mail, LDAPS, APIs, load balancers, private CAs), from a list that is generated from reality.
Run the check from another machine and another network, on a schedule, through a runner that logs, locks and alerts, and alert on the checker going silent.
Give every certificate an owner and a written renewal procedure, and practise the manual renewal once a year.
Treat shorter and shorter certificate lifetimes as a reason to automate and monitor, not as a reason to renew by hand more often.