🔐

Building SSL Certificate Expiry Alerts with Pure OpenSSL and Bash: A Zero-Dependency Monitoring Guide

· Server Scout

Your production website goes down at 2 AM because an SSL certificate expired. The hosting provider's dashboard showed green lights, but nobody was actually checking certificate expiry dates. Sound familiar?

This guide gives you a complete, copy-paste SSL certificate monitoring script using nothing but bash and openssl — no gems, no pip packages, no node modules. Just a single file you drop on any Linux box.

The Complete Script

Create /opt/ssl-monitor/check_certs.sh and paste the following:

#!/usr/bin/env bash
# SSL Certificate Expiry Monitor
# Usage: ./check_certs.sh [config_file]
# Config format: one domain per line (optional :port, default 443)

set -euo pipefail

CONFIG_FILE="${1:-/opt/ssl-monitor/domains.conf}"
LOG_FILE="/opt/ssl-monitor/ssl-monitor.log"
STATE_FILE="/opt/ssl-monitor/alert_state"
WARN_DAYS=30
URGENT_DAYS=14
CRIT_DAYS=7
TIMEOUT=10

# Where to send alerts (leave empty to disable)
EMAIL_TO=""
WEBHOOK_URL=""

log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG_FILE"; }

check_cert() {
    local domain="$1"
    local port="${2:-443}"

    local expiry_str
    expiry_str=$(timeout "$TIMEOUT" openssl s_client \
        -connect "${domain}:${port}" \
        -servername "$domain" \
        </dev/null 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null \
        | cut -d= -f2)

    if [[ -z "$expiry_str" ]]; then
        log "ERROR  ${domain}:${port} — could not retrieve certificate"
        send_alert "$domain" "$port" "ERROR" "Could not retrieve certificate"
        return 1
    fi

    local expiry_epoch now_epoch days_left
    expiry_epoch=$(date -d "$expiry_str" +%s)
    now_epoch=$(date +%s)
    days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

    local level="OK"
    if (( days_left <= 0 )); then
        level="EXPIRED"
    elif (( days_left <= CRIT_DAYS )); then
        level="CRITICAL"
    elif (( days_left <= URGENT_DAYS )); then
        level="URGENT"
    elif (( days_left <= WARN_DAYS )); then
        level="WARNING"
    fi

    local expiry_date
    expiry_date=$(date -d "$expiry_str" '+%Y-%m-%d')
    log "$(printf '%-8s %-40s %4d days  (expires %s)' "$level" "${domain}:${port}" "$days_left" "$expiry_date")"

    if [[ "$level" != "OK" ]]; then
        send_alert "$domain" "$port" "$level" "${days_left} days until expiry ($expiry_date)"
    fi
}

send_alert() {
    local domain="$1" port="$2" level="$3" message="$4"
    local state_key="${domain}_${port}"
    local today
    today=$(date +%Y-%m-%d)

    # Deduplicate: only alert once per domain per day per level
    local last_alert
    last_alert=$(grep "^${state_key}|" "$STATE_FILE" 2>/dev/null | tail -1 || true)
    if [[ "$last_alert" == "${state_key}|${today}|${level}" ]]; then
        return
    fi
    echo "${state_key}|${today}|${level}" >> "$STATE_FILE"

    local subject="SSL ${level}: ${domain}"
    local body="${subject} — ${message}"

    if [[ -n "$EMAIL_TO" ]]; then
        echo "$body" | mail -s "$subject" "$EMAIL_TO" 2>/dev/null || true
    fi

    if [[ -n "$WEBHOOK_URL" ]]; then
        curl -sf -X POST "$WEBHOOK_URL" \
            -H "Content-Type: application/json" \
            -d "{\"text\":\"${body}\"}" \
            --max-time 10 >/dev/null 2>&1 || true
    fi
}

# --- Main ---
if [[ ! -f "$CONFIG_FILE" ]]; then
    echo "Config file not found: $CONFIG_FILE"
    echo "Create it with one domain per line, e.g.:"
    echo "  example.com"
    echo "  internal.example.com:8443"
    exit 1
fi

mkdir -p "$(dirname "$LOG_FILE")"
touch "$STATE_FILE"

log "--- SSL certificate check started ---"

fail_count=0
total=0

while IFS= read -r line || [[ -n "$line" ]]; do
    line="${line%%#*}"          # strip comments
    line="${line// /}"         # strip spaces
    [[ -z "$line" ]] && continue

    domain="${line%%:*}"
    port="${line#*:}"
    [[ "$port" == "$domain" ]] && port=443

    ((total++))
    check_cert "$domain" "$port" || ((fail_count++))
done < "$CONFIG_FILE"

log "--- Checked ${total} domains, ${fail_count} error(s) ---"
exit $fail_count

Then create your domain list at /opt/ssl-monitor/domains.conf:

# One domain per line — port 443 is the default
example.com
app.example.com
api.example.com:8443
# internal.staging.com    ← commented out

Setup

Make it executable and create the directory structure:

sudo mkdir -p /opt/ssl-monitor && sudo chown $(whoami) /opt/ssl-monitor && chmod +x /opt/ssl-monitor/check_certs.sh

Run it manually first to verify everything works: ./opt/ssl-monitor/check_certs.sh. You'll see output like this for each domain:

OK example.com:443 247 days (expires 2026-10-25)

WARNING app.example.com:443 22 days (expires 2026-03-14)

How It Works

The script connects to each domain using openssl s_client with SNI support (the -servername flag), extracts the certificate's expiry date, and calculates days remaining. It classifies each certificate into one of four alert levels: WARNING (30 days), URGENT (14 days), CRITICAL (7 days), or EXPIRED.

Alert deduplication prevents notification spam — each domain/level combination only triggers one alert per day, tracked via a simple state file. No database needed.

The timeout wrapper on the openssl call prevents hanging connections from blocking the entire run, which matters when you're checking dozens of domains and one server is unresponsive.

Scheduling with Cron

Add a daily check at 8 AM:

crontab -e

0 8 * /opt/ssl-monitor/check_certs.sh 2>&1

For environments managing hundreds of certificates, you might want to run twice daily. The script is idempotent and the deduplication prevents duplicate alerts regardless of frequency.

Configuring Alerts

Edit the variables at the top of the script:

Email: Set EMAIL_TO="ops@yourcompany.com" — requires a working mail command on the system (postfix, sendmail, or msmtp).

Webhooks: Set WEBHOOK_URL to a Slack incoming webhook, Discord webhook, or any endpoint that accepts JSON POST requests with a text field.

Thresholds: Adjust WARNDAYS, URGENTDAYS, and CRITDAYS to match your renewal workflow. If your certificates auto-renew via Let's Encrypt, you might lower WARNDAYS to 14 since renewals happen at 30 days remaining.

Edge Cases Worth Knowing

Certificates behind CDNs: The script checks whatever certificate the endpoint presents. If Cloudflare or a CDN sits in front, you're checking their certificate, not your origin. To monitor origin certificates, connect directly to the origin IP while keeping the -servername flag set to your domain.

Self-signed certificates: The script only reads the expiry date — it doesn't validate the trust chain. Self-signed certificates work fine without modification.

Client certificate authentication: If a server requires mutual TLS, add -cert client.pem -key client.key to the openssl sclient command inside the checkcert function.

Wildcard certificates: These work identically to standard certificates. The expiry date is the same regardless of which subdomain you connect through.

Why Not Use an External Service?

External certificate monitoring services work until they don't — their own outages, rate limits, or the fact that they can't reach internal certificates behind firewalls. A local script running via cron has zero external dependencies and monitors internal services that no external tool can see.

For broader infrastructure monitoring beyond just certificates, Server Scout provides lightweight server monitoring with built-in alerting that complements this kind of targeted scripting. The bash agent approach means your monitoring itself doesn't become another heavy dependency to manage.

FAQ

How do I handle certificates behind load balancers or CDNs?

Use the -servername flag with the actual domain name, even when connecting to a different IP address. For CDN-protected sites, you may need to connect directly to origin servers using their IP addresses while specifying the correct hostname.

Can this monitor internal certificates or self-signed certificates?

Yes. The script only extracts the expiry date and doesn't validate the trust chain, so self-signed certificates work without any modification.

How do I monitor certificates that require client authentication?

Add -cert client.pem -key client.key flags to the openssl command inside the check_cert function. Store client certificates securely and ensure proper file permissions (600) to protect private keys.

Ready to Try Server Scout?

Start monitoring your servers and infrastructure in under 60 seconds. Free for 3 months.

Start Free Trial