Check SSL/TLS Certificate Expiry

What this does

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

check-certs.sh
#!/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.

hosts.txt
# 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 -servername flag on openssl s_client sends 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 (or smtp, ldap, pop3) to the openssl s_client command. 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 -showcerts dumps 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