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.