
Automate SSL Certificate Expiry Monitoring Across Many Domains
Before you run this
This guide builds a small bash script that connects to a list of hostnames over TLS, reads the expiry date from each certificate, and emails you a summary of anything expiring within a threshold you choose (30 days by default). It is a read-only monitor: it makes outbound TLS connections and sends mail. It does not touch, renew, or modify any certificate, and it changes nothing on the servers it checks.
Privileges: the checking script needs no root at all — any unprivileged user who can make outbound connections on port 443 can run it. You only need sudo for two setup steps: installing packages (openssl, a mail sender) and, if you choose, dropping a file into a system-wide cron directory. If you install the cron job under your own user with crontab -e, you never need root.
Test first: read the script before you run it, and run it by hand against one or two hostnames before you schedule anything. Confirm it prints sensible dates and that the email actually arrives — a monitor that silently fails to send is worse than none. Try it on a workstation or a test VM before you put it on a production box.
Reversibility: nothing here is destructive, so there is little to undo. The only persistent changes are the script file you create and the cron entry you add; removing both returns the system to where it started. The one thing to get right is mail delivery, because a broken mailer means missed warnings.
What I assume
- Debian 12 or Ubuntu 22.04+, bash as the shell, and
cronpresent (it is, on both). opensslis installed (it is, by default on both).- You have a working way to send mail from the box. I use the
mailcommand from thebsd-mailxpackage, pointed at whatever local MTA or smarthost you already have. If mail from the command line does not work on your host yet, fix that first — that is its own topic. - On RHEL/Alma the same script works; the mail package is
mailx(froms-nail) instead ofbsd-mailx, and you usednfinstead ofapt.
Install what you need:
sudo apt update
sudo apt install openssl bsd-mailx # RHEL/Alma: sudo dnf install openssl mailx
The core check
The whole thing rests on two standard openssl behaviours. First, s_client opens a TLS connection and hands the server certificate to x509, which prints the expiry date:
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -enddate
# prints: notAfter=Jun 3 12:00:00 2025 GMT
-servername sends SNI, which you need whenever one IP serves multiple certs (almost always, these days). The echo | gives s_client an empty stdin so it closes the connection instead of hanging.
Second, x509 -checkend <seconds> exits 0 if the certificate is still valid that many seconds from now, and 1 if it has expired or will within that window. That is the honest, built-in way to test a threshold — no date arithmetic to get wrong:
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -checkend 2592000 # 2592000 = 30 days in seconds
echo $? # 0 = fine, 1 = expiring within 30 days (or already expired)
I use -enddate for the human-readable report and -checkend for the pass/fail decision.
The domain list
Keep the hostnames in a plain text file, one host:port per line, so you can add and remove without touching the script. Comments and blanks are ignored.
# /etc/ssl-expiry/domains.txt
example.com:443
mail.example.com:443
www.example.org:443
Replace those with your real hostnames. The :port lets you monitor things that are not on 443 — a submission port, an internal service — but if you omit it the script below defaults to 443.
The script
Save this as /usr/local/bin/ssl-expiry-check.sh and chmod +x it. Read it through first; the non-obvious lines are commented.
#!/usr/bin/env bash
set -u
# --- Settings you edit ---
DOMAINS_FILE="/etc/ssl-expiry/domains.txt" # one host:port per line
THRESHOLD_DAYS=30 # warn this many days ahead
MAILTO="admin@example.com" # where the report goes
TIMEOUT=10 # seconds to wait per host
# -------------------------
THRESHOLD_SECONDS=$(( THRESHOLD_DAYS * 86400 ))
report=""
problems=0
while IFS= read -r line; do
# skip blank lines and comments
[ -z "$line" ] && continue
case "$line" in \#*) continue ;; esac
host="${line%%:*}"
port="${line##*:}"
[ "$port" = "$host" ] && port=443 # no ":port" given, default to 443
# Fetch the certificate once. timeout guards against a host that never answers.
cert=$(echo | timeout "$TIMEOUT" \
openssl s_client -servername "$host" -connect "$host:$port" 2>/dev/null)
if [ -z "$cert" ]; then
report+="UNREACHABLE $host:$port (no TLS response)"$'\n'
problems=$(( problems + 1 ))
continue
fi
enddate=$(echo "$cert" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
# -checkend exits non-zero if the cert expires within the window
if echo "$cert" | openssl x509 -noout -checkend "$THRESHOLD_SECONDS" >/dev/null 2>&1; then
: # fine, say nothing — keep the report short
else
report+="EXPIRING $host:$port ($enddate)"$'\n'
problems=$(( problems + 1 ))
fi
done < "$DOMAINS_FILE"
# Only mail when there is something to say.
if [ "$problems" -gt 0 ]; then
printf 'Certificates needing attention (threshold %s days):\n\n%s\n' \
"$THRESHOLD_DAYS" "$report" \
| mail -s "SSL expiry warning: $problems host(s)" "$MAILTO"
fi
exit 0
A note on design choices. It fetches each certificate once and reuses it for both the report and the threshold test, so it is not double-connecting. It mails only when there is a problem — a nightly "all clear" trains you to ignore the mail. If you would rather always get a summary, remove the if [ "$problems" -gt 0 ] guard and pipe the report unconditionally; it is worth deciding which you want. The timeout wrapper means one dead host cannot hang the whole run.
Run it by hand first
Point DOMAINS_FILE at a file with one hostname you control, set THRESHOLD_DAYS deliberately high (say 3650) so it definitely triggers, and run it:
/usr/local/bin/ssl-expiry-check.sh
You should receive an email listing that host and its expiry date. Then set the threshold back to 30. This proves both the check and the mail path before you rely on it.
You can also verify the raw reading against a browser or against openssl directly, so you trust the date the script reports:
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -enddate
Schedule it
Install it under your own user so it needs no root. crontab -e, then add a line to run it every morning at 07:00:
0 7 * * * /usr/local/bin/ssl-expiry-check.sh
Cron mails its own output to the crontab owner if the script prints anything to stdout/stderr; this script is silent on success, so a quiet cron mailbox means it ran cleanly. If you would rather run it system-wide, drop the same line (with a username field) into a file under /etc/cron.d/ — that step needs sudo.
Verify and undo
Verify it is scheduled: crontab -l shows the line. To confirm the scheduled run really fires, temporarily set the cron time a couple of minutes ahead, watch for the mail (or check /var/log/syslog on Debian/Ubuntu, journalctl -u cron, for the CRON entry showing the command ran), then set it back.
Undo: there is nothing destructive to reverse. To remove the monitor entirely, delete the cron line with crontab -e (or remove the /etc/cron.d/ file with sudo), then delete /usr/local/bin/ssl-expiry-check.sh and the /etc/ssl-expiry/ directory. The packages you installed can stay; openssl in particular is part of the base system.
For the exact behaviour of the flags used here, see the OpenSSL manual pages for openssl-s_client and openssl-x509 (the -checkend and -enddate options are documented on the latter).
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
