Monitor SSL Certificate Expiry and Alert Early

An expired SSL certificate takes your site offline for every visitor and triggers browser security warnings that destroy user trust. This recipe sets up a daily scheduled script that checks how many days remain on your certificate and fires an alert when it drops below your chosen threshold. Combined with a CronJobPro heartbeat monitor, you will know both when the cert is about to expire and when the check itself silently stops running.

Schedule

0 8 * * *

Every day at 8:00 AM server time

Setup

  1. 1

    Install dependencies

    The script uses openssl and curl, which are available on virtually all Linux distributions. Verify with: openssl version && curl --version. No additional packages are needed.

  2. 2

    Create the script file

    Save the script below to a file on your server, for example /usr/local/bin/check-ssl-expiry.sh, then make it executable with: chmod +x /usr/local/bin/check-ssl-expiry.sh

  3. 3

    Set your variables

    Edit the top of the script and set DOMAIN to the hostname you want to check (e.g. example.com), WARN_DAYS to the number of days before expiry you want to be alerted (30 is a safe default), and ALERT_EMAIL to the address that should receive warnings.

  4. 4

    Add the cron entry

    Run crontab -e and add the line shown below. The schedule runs the check every day at 8 AM. Example: 0 8 * * * /usr/local/bin/check-ssl-expiry.sh >> /var/log/check-ssl-expiry.log 2>&1

  5. 5

    Create a CronJobPro heartbeat and add the ping URL

    In CronJobPro, create a new Heartbeat monitor with a period of 25 hours and a grace period of 2 hours. Copy the generated ping URL (https://cronjobpro.com/ping/<token>) and paste it into the HEARTBEAT_URL variable at the top of the script. From this point on, CronJobPro will alert you if the check stops running for any reason.

The script

bash

#!/usr/bin/env bash
set -euo pipefail

# --- Configuration ---
DOMAIN="example.com"
PORT="443"
WARN_DAYS=30
ALERT_EMAIL="you@example.com"
HEARTBEAT_URL="https://cronjobpro.com/ping/YOUR_TOKEN_HERE"
# ---------------------

check_cert_expiry() {
  local domain="$1"
  local port="$2"

  # Fetch the certificate expiry date from the live TLS handshake
  local expiry_date
  expiry_date=$(
    echo | openssl s_client -servername "$domain" -connect "${domain}:${port}" 2>/dev/null \
      | openssl x509 -noout -enddate 2>/dev/null \
      | cut -d= -f2
  )

  if [[ -z "$expiry_date" ]]; then
    echo "ERROR: Could not retrieve certificate for ${domain}:${port}" >&2
    curl -fsS --retry 3 "${HEARTBEAT_URL}/fail" > /dev/null 2>&1 || true
    exit 1
  fi

  # Convert expiry date to epoch seconds (works on Linux with GNU date)
  local expiry_epoch
  expiry_epoch=$(date -d "$expiry_date" +%s)

  local now_epoch
  now_epoch=$(date +%s)

  local seconds_remaining=$(( expiry_epoch - now_epoch ))
  local days_remaining=$(( seconds_remaining / 86400 ))

  echo "Certificate for ${domain} expires in ${days_remaining} day(s) (on ${expiry_date})"

  if (( days_remaining <= 0 )); then
    local subject="CRITICAL: SSL certificate for ${domain} has EXPIRED"
    local body="The SSL certificate for ${domain} expired on ${expiry_date}. Renew immediately."
    echo "$body" | mail -s "$subject" "$ALERT_EMAIL"
    echo "ALERT sent: certificate expired."
    curl -fsS --retry 3 "${HEARTBEAT_URL}/fail" > /dev/null 2>&1 || true
    exit 2
  elif (( days_remaining <= WARN_DAYS )); then
    local subject="WARNING: SSL certificate for ${domain} expires in ${days_remaining} day(s)"
    local body="The SSL certificate for ${domain} will expire on ${expiry_date} (${days_remaining} days remaining). Please renew it soon."
    echo "$body" | mail -s "$subject" "$ALERT_EMAIL"
    echo "ALERT sent: ${days_remaining} days remaining."
  else
    echo "OK: certificate is valid for ${days_remaining} more day(s). No alert needed."
  fi
}

check_cert_expiry "$DOMAIN" "$PORT"

# Ping the heartbeat to confirm the check completed successfully.
# CronJobPro will alert you if this ping stops arriving.
curl -fsS --retry 3 "$HEARTBEAT_URL" > /dev/null 2>&1 || true

exit 0

Monitor it

Create a Heartbeat monitor in CronJobPro and set its expected period to 25 hours with a grace period of 2 hours, giving the daily job a comfortable window to check in. Paste the generated URL (https://cronjobpro.com/ping/your-token) into the HEARTBEAT_URL variable in the script. On every successful run the script calls that URL, resetting the countdown. If the ping does not arrive within the period plus grace, CronJobPro treats the job as missed and sends you an alert through whichever channels you have configured: email, Slack, Discord, Teams, PagerDuty, Opsgenie, or a custom webhook. If the script encounters an error retrieving the certificate or detects the cert has already expired, it calls /ping/your-token/fail instead, which triggers an immediate failure alert rather than waiting for the timeout. This means you get two independent warning layers: the mail alert from the script itself when days are running low, and a separate infrastructure alert from CronJobPro if the check process itself ever breaks or the server goes silent.

Frequently asked questions

What happens if the domain is behind a CDN or load balancer?

The script connects directly to the domain on port 443 using a TLS handshake, so it checks whichever certificate the CDN or load balancer presents. This is usually the certificate your visitors see, which is exactly what you want to monitor. If the CDN manages certificate renewal separately from your origin, you may also want to run a second instance of the script pointed at your origin IP on a non-standard port.

Can I check multiple domains with the same script?

Yes. The simplest approach is to copy the cron entry and script for each domain, each with its own DOMAIN variable and its own CronJobPro heartbeat token. Alternatively you can refactor the script to loop over a list of domains in a DOMAINS array and send a combined report, though you would then share a single heartbeat across all checks.

Why use openssl s_client instead of a third-party API?

Connecting directly with openssl s_client checks the real certificate your server is serving right now, including any intermediate chain issues. Third-party APIs add an external dependency and may lag behind live state, cache results, or have rate limits. The openssl approach works on any Linux server with no additional accounts or tokens required.

The script fails with 'date: invalid date' on macOS. How do I fix it?

macOS ships with BSD date, which uses a different syntax for parsing date strings. Replace the date -d line with: expiry_epoch=$(date -j -f '%b %d %T %Y %Z' "$expiry_date" +%s). Alternatively, install GNU coreutils via Homebrew (brew install coreutils) and invoke gdate instead of date.

More recipes

Monitor SSL Certificate Expiry and Alert Early | CronJobPro