Check SSL/TLS Certificate Expiry
Reads a list of hostname:port entries (port defaults to 443) and prints the days remaining on each certificate. Exits non-zero if any certificate is expiring within a configurable warning window, which makes it suitable for cron-and-email or for a monitoring system that watches script exit codes.
When this is useful
Most certificates today are issued for short lifetimes and renewed automatically by ACME clients such as certbot or acme.sh. The automation works almost all of the time. The cases where it doesn't — a renewal that quietly failed after a DNS change, a service that picked up the old cert from a cache, a hostname that never got added to the renewal list — are the ones that page you at 3am.
A small daily check across all the hostnames you care about is a cheap insurance policy. It also works for certificates issued by an internal certificate authority, where ACME might not apply.
The one-liner
Before reaching for the script, the underlying check is one line of openssl:
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -enddate
The output is the certificate's notAfter date:
notAfter=Aug 5 12:00:00 2026 GMT
That's enough to glance at one host. The script below wraps this for many hosts and computes "days remaining" so you don't have to.
The script
#!/usr/bin/env bash
#
# check-certs.sh — check TLS certificate expiry for a list of hosts.
#
# Usage:
# ./check-certs.sh hosts.txt
# ./check-certs.sh --warn 14 hosts.txt
#
# Input file: one entry per line, either "hostname" or "hostname:port".
# Lines starting with # and blank lines are ignored.
#
# Exit code:
# 0 every certificate has more than --warn days left
# 1 one or more certificates are within --warn days of expiry
# 2 one or more certificates could not be retrieved (treated as failure)
#
set -euo pipefail
WARN_DAYS=30
INPUT=""
while [ "$#" -gt 0 ]; do
case "$1" in
--warn) WARN_DAYS="$2"; shift ;;
-h|--help) sed -n '2,15p' "$0"; exit 0 ;;
*) INPUT="$1" ;;
esac
shift
done
if [ -z "$INPUT" ] || [ ! -r "$INPUT" ]; then
echo "Usage: $0 [--warn DAYS] hosts.txt" >&2
exit 2
fi
NOW_EPOCH=$(date +%s)
STATUS=0
printf '%-40s %10s %s\n' "HOST:PORT" "DAYS LEFT" "EXPIRES"
printf '%s\n' "$(printf -- '-%.0s' {1..80})"
while IFS= read -r line; do
# Skip blank lines and comments
case "$line" in ''|\#*) continue ;; esac
host="${line%%:*}"
port="${line##*:}"
[ "$port" = "$host" ] && port=443
end=$(echo \
| timeout 10 openssl s_client -servername "$host" -connect "$host:$port" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| sed -n 's/^notAfter=//p')
if [ -z "$end" ]; then
printf '%-40s %10s %s\n' "$host:$port" "ERROR" "could not retrieve certificate"
STATUS=2
continue
fi
end_epoch=$(date -d "$end" +%s 2>/dev/null || true)
if [ -z "$end_epoch" ]; then
printf '%-40s %10s %s\n' "$host:$port" "ERROR" "could not parse date: $end"
STATUS=2
continue
fi
days_left=$(( (end_epoch - NOW_EPOCH) / 86400 ))
marker=""
if [ "$days_left" -lt 0 ]; then
marker="EXPIRED"
STATUS=1
elif [ "$days_left" -lt "$WARN_DAYS" ]; then
marker="!"
[ "$STATUS" -eq 0 ] && STATUS=1
fi
printf '%-40s %10d %s %s\n' "$host:$port" "$days_left" "$end" "$marker"
done < "$INPUT"
exit "$STATUS"
The input file
The script takes a plain text file with one host per line. Comments and blank lines are skipped.
# Public sites
example.com
www.example.com
api.example.com:443
# Non-default ports
mail.example.com:993
ldap.example.com:636
# Internal sites (DNS must resolve from where the script runs)
intranet.example.local
Example output
$ ./check-certs.sh --warn 21 hosts.txt
HOST:PORT DAYS LEFT EXPIRES
--------------------------------------------------------------------------------
example.com:443 62 Jul 13 23:59:59 2026 GMT
www.example.com:443 4 May 17 11:30:00 2026 GMT !
api.example.com:443 90 Aug 10 12:00:00 2026 GMT
mail.example.com:993 -3 May 10 09:14:21 2026 GMT EXPIRED
ldap.example.com:636 184 Nov 13 00:00:00 2026 GMT
$ echo $?
1
The exit code tells a monitoring system or a cron job that something needs attention without it having to parse the output. The default warning window is 30 days; override with --warn 14 for a tighter threshold.
Sending the output by email from cron
Cron jobs send any output to root's mailbox (or to MAILTO= if you set it). The trick is to send mail only when there's something to report:
# /etc/cron.d/check-certs
[email protected]
# Every morning at 06:00. Send mail only if something is expiring or unreachable.
0 6 * * * root /usr/local/sbin/check-certs.sh --warn 21 /etc/check-certs/hosts.txt || /usr/local/sbin/check-certs.sh --warn 21 /etc/check-certs/hosts.txt
The pattern cmd || cmd reruns the script if the first run returned non-zero, so that the output is captured and emailed only on failure. On a green day, you get nothing in your inbox.
The systemd version
If you'd rather not use cron, the same job as a systemd timer (see systemd basics):
# /etc/systemd/system/check-certs.service
[Unit]
Description=Daily TLS certificate expiry check
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/check-certs.sh --warn 21 /etc/check-certs/hosts.txt
# /etc/systemd/system/check-certs.timer
[Unit]
Description=Run check-certs daily
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
Output goes to the journal automatically; journalctl -u check-certs.service shows the most recent run. Wire it to an alerting system by watching systemctl is-failed check-certs.service or by parsing the journal.
Caveats
- SNI is required for the modern web. The
-servernameflag onopenssl s_clientsends the Server Name Indication, without which most virtual hosts will hand back the wrong certificate or the default one. The script includes it. - Some services don't speak TLS on port 993, 636, etc., immediately. They use STARTTLS — an upgrade from plaintext. For those, add
-starttls imap(orsmtp,ldap,pop3) to theopenssl s_clientcommand. The script does not handle STARTTLS automatically; extending it is a small exercise. - The script reports the leaf certificate only. Intermediate or root CAs in the chain can also expire and cause failures.
openssl s_client -showcertsdumps the whole chain. - Run this from a network that can reach the hostnames. Internal services may not be reachable from your laptop; in that case, run the script on a host inside the network instead.
Related reading
- Linux security overview — where certificate monitoring sits in a wider hardening plan.
- systemd basics — for the timer pattern above.
- Essential Linux commands — reference for the commands the script uses.
- Back to all scripts