Table of Contents
- How bad is a certificate outage, really?
- Why isn't automated renewal enough?
- How do I check a certificate's expiry from the command line?
- Catching the renewed-but-not-reloaded case
- What thresholds should a certificate alert use?
- How do I turn this into an alert that reaches me?
- Step 1 — Create two channels, not one
- Step 2 — Run the check
- Step 3 — Schedule it, and alert when the schedule itself fails
- Step 4 — Split the ladder with conditions
- Step 5 — Test it before you trust it
- What if my monitoring already checks certificates?
- What Echobell does not do
- FAQ
- Did Let's Encrypt really stop sending expiration emails?
- How long are TLS certificates valid in 2026?
- Should I alert on expiry at all if I use ARI?
- What about certificates that aren't on a web server?
- Won't a daily check become noise?
- Can the whole team get the certificate alert?
- Does this work on macOS?
- Is it safe to send certificate details in a notification?
- Related
Two things changed underneath everyone's certificate setup, and most teams have not adjusted for either.
First, the safety net was removed. Let's Encrypt shut down its expiration notification service on 4 June 2025 — the emails that quietly saved thousands of sites when automation broke. The reasoning was sound (most subscribers automate renewal, storing millions of email addresses is a privacy liability, and the service cost "tens of thousands of dollars per year"), and the post ends by telling you to go find third-party monitoring instead (Let's Encrypt). Many people read that, agreed, and never did the second half.
Second, the margin for error collapsed. Since 15 March 2026 a public TLS certificate can be valid for at most 200 days, dropping to 100 days on 15 March 2027 and 47 days on 15 March 2029 under CA/Browser Forum ballot SC-081v3. Let's Encrypt is moving faster than the cap requires: 6-day certificates went generally available on 15 January 2026, and its plan takes the default profile to 64 days in February 2027 and 45 days in February 2028 (Let's Encrypt).
Both changes push in the same direction. Renewal happens more often, so it has more chances to break, and when it breaks nothing sends you mail. This guide is the check that closes that gap — a tested shell script, thresholds that make sense for short-lived certificates, and a way to make the last-resort case ring your phone with Echobell.
How bad is a certificate outage, really?
Bad enough that more than a third of organizations had one last year. In DigiCert's 2026 Global Certificate Management Outlook — a Propeller Insights survey of 1,001 IT and cybersecurity decision makers across the US, UK and Australia, conducted in May 2026 — more than one-third of organizations reported a service outage caused by an expired certificate in the past year. Nearly three-quarters reported at least five hours of certificate-related downtime, one in five reported 25 hours or more, and nearly one in four said their most significant certificate incident cost more than $250,000 (DigiCert).
The interesting part of those numbers is the duration. Five hours is not how long it takes to renew a certificate — renewal takes seconds. Five hours is how long it took somebody to find out.
Why isn't automated renewal enough?
Because renewal automation fails silently, and a cron job that stopped running produces no output at all. Every one of these is a real, common failure, and none of them makes a sound:
- The timer is no longer running. A distribution upgrade, a container rebuild, or a
systemctl disablethree months ago that nobody remembers.certbot.timernot firing looks exactly likecertbot.timerfiring successfully. - The renewal succeeded but the service never reloaded. The new certificate is on disk; nginx, HAProxy, or Postfix is still holding the old one in memory. This is the single most common way a "fully automated" setup expires anyway.
- The challenge path broke. Somebody added a redirect, a WAF rule, or a
Denyin front of/.well-known/acme-challenge/, so HTTP-01 fails. Or the DNS provider API token used for DNS-01 expired. - Renewal happened on one node. Two load balancers, one cron job. The second one keeps serving the old certificate until it doesn't.
- The certificate isn't on a web server at all. Internal mTLS clients, a Kafka broker, an LDAP server, a VPN concentrator, a device management push certificate. Nothing on the public internet can see it and no ACME client is managing it.
- Renewal is hardcoded to the wrong interval. Let's Encrypt is explicit about this one: "renewing at a hardcoded interval of 60 days will no longer be sufficient" once the default profile drops to 64 and then 45 days (Let's Encrypt).
The last one deserves emphasis because it is the failure the calendar is walking everyone into. If your renewal cadence is a number someone typed in 2022, the certificate lifetime is now moving toward it.
How do I check a certificate's expiry from the command line?
One openssl pipeline, no dependencies. For a live host:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -startdate -enddate
For a file on disk:
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate
The -servername flag is not optional on a shared IP — without SNI you get whatever certificate the server considers its default, which may not be the one you are worried about.
For a yes/no answer, skip the date parsing entirely. openssl x509 -checkend <seconds> exits 0 if the certificate survives that window and 1 if it expires inside it (including if it has already expired):
openssl x509 -in fullchain.pem -noout -checkend $((14 * 86400)) \
|| echo "expires within 14 days"
That exit-code contract is the whole monitoring primitive. Everything below is just plumbing around it.
Catching the renewed-but-not-reloaded case
Compare what is on disk with what is actually being served. This is the check almost nobody runs, and it catches the failure mode that renewal automation cannot see:
served=$(openssl s_client -connect 127.0.0.1:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256)
ondisk=$(openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -fingerprint -sha256)
[ "$served" = "$ondisk" ] || echo "service is serving a stale certificate — reload needed"
Both commands print the identical sha256 Fingerprint=AB:CD:... format, so a plain string comparison is enough. Run it a few minutes after your renewal timer's window.
What thresholds should a certificate alert use?
Fractions of the certificate's lifetime, not fixed day counts. A "warn at 30 days" rule was reasonable for 90-day certificates. Applied to a 47-day certificate it fires on a perfectly healthy cert; applied to a 6-day certificate it fires permanently.
Anchor the ladder to the renewal point instead. Let's Encrypt recommends renewing "at approximately two thirds of the way through the current certificate's lifetime" — so one third of lifetime remaining is when renewal should already have happened. Everything after that point is evidence that it didn't:
| Remaining lifetime | What it means | Notification type |
|---|---|---|
| 1/3 | Renewal window opened | Nothing — this is normal |
| 1/6 | Renewal window missed once | Normal push |
| 1/12 | Renewal is failing, not late | Time Sensitive |
| < 1/24, expired, or unreachable | You are hours from an outage | Calling |
In concrete numbers, for a 47-day certificate that is roughly: quiet until 7.8 days, a push at 3.9 days, time-sensitive at 2 days, a call under 1 day. For a 90-day certificate: 15 days, 7.5 days, 3.75 days. For a 6-day certificate: hours, and a human ladder stops being the right tool — rely on ACME Renewal Information (ARI, published as RFC 9773), which lets the CA tell your client when to renew, and alert only on repeated client failure.
Computing the fraction is four lines, and it makes the same script correct across every certificate you own regardless of issuer:
to_epoch() {
date -u -d "$1" +%s 2>/dev/null || date -u -j -f '%b %d %T %Y %Z' "$1" +%s 2>/dev/null
}
pct_left() { # reads a PEM on stdin, prints percent of lifetime remaining
local pem nb na
pem=$(cat)
nb=$(to_epoch "$(printf '%s' "$pem" | openssl x509 -noout -startdate | cut -d= -f2)")
na=$(to_epoch "$(printf '%s' "$pem" | openssl x509 -noout -enddate | cut -d= -f2)")
echo $(( (na - $(date -u +%s)) * 100 / (na - nb) ))
}
The first date form is GNU, the second is BSD/macOS; the || picks whichever one you have.
How do I turn this into an alert that reaches me?
Echobell turns a webhook or an email into a push, a time-sensitive alert, or a real ringing phone call that cuts through Focus Mode and Do Not Disturb (see bypassing iOS Focus Mode). For certificates that matters because the last threshold on the ladder above is the one you will hit at 03:00 on a Sunday.
Step 1 — Create two channels, not one
Create a channel in the app, set its notification type to Time Sensitive, and name it "Certs expiring". Create a second one set to Calling and name it "Cert about to expire". Copy each webhook URL from the channel details — they look like https://hook.echobell.one/t/<channel-token>. Treat them as secrets; anyone holding the Calling one can ring your phone (webhook guide).
Set the templates so the notification is actionable from a lock screen without unlocking anything:
Title: TLS {{state}}: {{host}}
Body: {{daysLeft}} days left, expires {{notAfter}} — issued by {{issuer}}
Any JSON key you post becomes a variable (templates).
Step 2 — Run the check
This is the script from the sections above, assembled and tested end to end. Save it as /usr/local/bin/cert-watch:
#!/usr/bin/env bash
# cert-watch — POST to Echobell when a TLS certificate is close to expiry.
set -uo pipefail
HOOK="${ECHOBELL_CERT_HOOK:?set ECHOBELL_CERT_HOOK to your channel webhook URL}"
WARN_DAYS="${WARN_DAYS:-14}"
post() {
curl -sS -m 10 -X POST "$HOOK" \
-H 'content-type: application/json' \
-d "{\"host\":\"$1\",\"daysLeft\":$2,\"notAfter\":\"$3\",\"state\":\"$4\"}" \
>/dev/null
}
days_left() {
local end
end=$(date -u -d "$1" +%s 2>/dev/null) ||
end=$(date -u -j -f '%b %d %T %Y %Z' "$1" +%s 2>/dev/null) || return 1
echo $(( (end - $(date -u +%s)) / 86400 ))
}
check() {
local host="$1" port="$2" pem state not_after days
pem=$(openssl s_client -connect "$host:$port" -servername "$host" \
</dev/null 2>/dev/null | openssl x509 2>/dev/null)
if [ -z "$pem" ]; then
post "$host" 0 "" "unreachable"
return
fi
if printf '%s' "$pem" | openssl x509 -noout -checkend 0 >/dev/null 2>&1; then
printf '%s' "$pem" | openssl x509 -noout -checkend $((WARN_DAYS * 86400)) >/dev/null 2>&1 && return 0
state="expiring"
else
state="expired"
fi
not_after=$(printf '%s' "$pem" | openssl x509 -noout -enddate | cut -d= -f2)
days=$(days_left "$not_after") || days=-999
post "$host" "$days" "$not_after" "$state"
}
for target in "$@"; do
case "$target" in
*:*) check "${target%:*}" "${target##*:}" ;;
*) check "$target" 443 ;;
esac
done
It reports three states — expiring, expired, and unreachable — and stays silent when everything is fine. Targets are host or host:port, so non-web certificates are covered too:
ECHOBELL_CERT_HOOK="https://hook.echobell.one/t/<token>" \
cert-watch example.com api.example.com mail.example.com:993 ldap.internal:636
unreachable is deliberately an alert rather than a silent skip. A monitoring check that treats "I could not look" as "everything is fine" is the reason certificates expire in the first place.
Step 3 — Schedule it, and alert when the schedule itself fails
Once a day is enough for 45-day and longer certificates; twice a day if you run 6-day certificates. A systemd timer:
# /etc/systemd/system/cert-watch.service
[Unit]
Description=TLS certificate expiry check
[Service]
Type=oneshot
EnvironmentFile=/etc/echobell.env
ExecStart=/usr/local/bin/cert-watch example.com api.example.com mail.example.com:993
# /etc/systemd/system/cert-watch.timer
[Unit]
Description=Daily TLS certificate expiry check
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true matters: without it, a machine that was off at the scheduled time simply skips that run.
Then close the loop on the check itself. A Type=oneshot unit that exits non-zero triggers OnFailure=, so one drop-in makes a broken renewal announce itself:
# /etc/systemd/system/certbot.service.d/echobell.conf
[Unit]
OnFailure=echobell-alert@%n.service
# /etc/systemd/system/echobell-alert@.service
[Unit]
Description=Echobell alert for %i
[Service]
Type=oneshot
EnvironmentFile=/etc/echobell.env
ExecStart=/usr/local/bin/echobell-notify %i
Where /usr/local/bin/echobell-notify is three lines:
#!/usr/bin/env bash
curl -sS -m 10 -X POST "$ECHOBELL_CERT_HOOK" \
-H 'content-type: application/json' \
-d "{\"host\":\"$(hostname -f)\",\"state\":\"renewal-failed\",\"unit\":\"$1\",\"daysLeft\":-1}"
certbot renew exits non-zero when any renewal fails, which is exactly the signal you want and exactly the signal that currently goes nowhere. Note that certbot's --deploy-hook only runs on success, so it cannot be used for this — the failure path has to come from the unit.
Step 4 — Split the ladder with conditions
Both channels receive the same payload; conditions decide which one actually fires. On the Time Sensitive channel:
state == "expiring" && daysLeft > 3
On the Calling channel:
state == "expired" || state == "unreachable" || state == "renewal-failed" || daysLeft <= 3
Note that <= coerces both sides with Number(), so a daysLeft sent as a string still compares numerically. Conditions have no "contains" operator, which is why the script sends an explicit state field instead of a free-text message you would have to pattern-match.
Step 5 — Test it before you trust it
Point the script at a host with a known-bad certificate and watch the alert arrive:
ECHOBELL_CERT_HOOK="https://hook.echobell.one/t/<token>" cert-watch expired.badssl.com
Do this with Do Not Disturb enabled on the phone that will actually receive it, and turn on Retry Failed Call in the app so a call suppressed by Focus Mode is attempted again. An escalation path you have never fired is a guess.
What if my monitoring already checks certificates?
Then wire its existing webhook to a channel and skip the script. Most monitoring already knows the expiry date; what it usually lacks is a path that survives a sleeping human.
- Uptime Kuma has a built-in certificate expiry notification — point it at a channel (Uptime Kuma guide, phone call setup).
- Prometheus + Alertmanager with the blackbox exporter gives you
probe_ssl_earliest_cert_expiry; alert on it and route to a channel (Prometheus guide, Alertmanager calls). - Grafana alert rules post to a channel directly (Grafana guide).
- Upptime and UptimeRobot both cover the public endpoints (Upptime, UptimeRobot).
- Anything that only emails — a CA's own portal, a cloud provider's ACM notices, an internal PKI — gets a forwarding rule instead. Every channel has its own address, and
from,to,subject,textandhtmlare available as variables (email triggers).
The one thing none of those replace is the check running from outside the box that serves the certificate. If your monitoring lives on the same host, a failure that takes the host down also takes the alert with it.
What Echobell does not do
Being precise matters here, because certificate management is a category full of products that do much more than this.
Echobell does: turn a webhook or an email into a normal push, a time-sensitive alert, or a ringing call; filter with conditions; format with templates; deliver one trigger to every subscriber of a shared channel, each choosing their own urgency.
Echobell does not:
- Discover or inventory your certificates. It does not scan your network, crawl certificate transparency logs, or tell you about the certificate nobody remembers issuing. The script above only checks the hosts you list. Certificate sprawl is a real problem and this is not a solution to it.
- Renew anything. It has no ACME client and no access to your keys. It tells you renewal broke; fixing it is still yours.
- Check certificates on its own schedule. There is no hosted probe. Something you run — a timer, a CI job, your existing monitoring — has to do the looking.
- Provide on-call rotations, escalation policies, or acknowledgement. There is no "if nobody answers in five minutes, call the next person." If you need that, you need an incident platform — see the Opsgenie alternatives comparison.
- Guarantee delivery. A call depends on push infrastructure, a network, and a charged phone. It shortens the gap between breakage and awareness; it is not a control you can lean on absolutely.
FAQ
Did Let's Encrypt really stop sending expiration emails?
Yes. The expiration notification service ended on 4 June 2025, and Let's Encrypt deleted the email addresses it had stored against issuance records. The announcement recommends third-party monitoring and points to Red Sift Certificates Lite, free for up to 250 certificates, as one option. If you have not received a Let's Encrypt expiry warning in over a year, that is why — not because nothing was ever close to expiring.
How long are TLS certificates valid in 2026?
A maximum of 200 days for publicly trusted TLS certificates, since 15 March 2026. The cap falls to 100 days on 15 March 2027 and 47 days on 15 March 2029 under ballot SC-081v3. Individual CAs issue under the cap for safety — DigiCert, for example, issues 199-day certificates specifically "to avoid exceeding the maximum permitted validity." Let's Encrypt is moving further and faster on its own schedule, with 6-day certificates generally available since January 2026.
Should I alert on expiry at all if I use ARI?
Yes, but on different events. ARI (RFC 9773) tells your ACME client when to renew, which removes the hardcoded-interval failure mode entirely — it does not guarantee the renewal succeeds, that the service reloads, or that the client is still running. Alert on repeated renewal failure and on the served certificate diverging from the one on disk, not on a countdown you no longer need to manage.
What about certificates that aren't on a web server?
Those are the ones most likely to expire, because no ACME client is watching them and no browser complains until something breaks. The script takes host:port, so IMAP on 993, LDAPS on 636, a Kafka broker on 9093, or an internal API on 8443 all work the same way. Certificates that never touch a socket — code signing, push notification certificates, client certificates in a device fleet — need a date pulled from wherever they live and posted to the same channel.
Won't a daily check become noise?
Not if it stays silent when nothing is wrong, which is why the script posts nothing above the threshold. The noisy design is the one that reports "certificate OK" every day — after two weeks nobody reads it, and the day it stops arriving nobody notices. If you want a heartbeat, put it on a separate Normal channel and never on the one that rings. See fixing alert fatigue for the general version of this argument.
Can the whole team get the certificate alert?
Yes. Share the channel and every subscriber receives the trigger, each choosing their own notification type. A workable split: whoever is on call subscribes to the Calling channel, everyone else takes the Time Sensitive one, so a 03:00 expiry wakes one person instead of six.
Does this work on macOS?
Yes, with one caveat: BSD date does not accept -d, which is why days_left and to_epoch try the GNU form first and fall back to date -u -j -f. openssl on macOS is LibreSSL by default and supports -checkend and -fingerprint identically. If you install OpenSSL from Homebrew, nothing changes.
Is it safe to send certificate details in a notification?
The fields here — hostname, expiry date, issuer — are public; anyone can read them off your server with the same openssl command. Don't extend the payload with private keys, internal paths, or anything from a certificate on a non-public host beyond the hostname. Echobell stores notification content and history only on your device, keeping just accounts, channels and subscriptions on the server (privacy model), which is a good default but not a reason to send more than you need.