You probably have more domains than you think. Production, staging, internal tools, customer subdomains, API endpoints, webhooks. Each one has an SSL certificate. Each certificate has an expiry date. Most of them renew automatically — until the automation silently breaks and nobody gets an alert.
This guide covers every practical approach to monitoring SSL certificate expiry at scale, from a ten-line shell script to managed monitoring services.
For a small list of domains, a shell script run daily is often enough. Save this as check-ssl.sh and add it to cron:
#!/usr/bin/env bash
# check-ssl.sh — alert when any certificate expires within WARN_DAYS
WARN_DAYS=30
DOMAINS=(
"example.com"
"api.example.com"
"staging.example.com"
"app.yourcompany.com"
)
for domain in "${DOMAINS[@]}"; do
expiry=$(echo | openssl s_client -connect "${domain}:443" \
-servername "$domain" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| cut -d= -f2)
if [ -z "$expiry" ]; then
echo "WARN: Could not check $domain"
continue
fi
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null \
|| date -j -f "%b %d %T %Y %Z" "$expiry" +%s 2>/dev/null)
now_epoch=$(date +%s)
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
if [ "$days_left" -lt "$WARN_DAYS" ]; then
echo "ALERT: $domain expires in $days_left days ($expiry)"
else
echo "OK: $domain — $days_left days remaining"
fi
done
Run it daily with cron:
# Check SSL expiry every morning at 8am and email if anything is close
0 8 * * * /usr/local/bin/check-ssl.sh 2>&1 | mail -s "SSL Expiry Check" ops@yourcompany.com
date -d flag is GNU-only. On macOS, use date -j -f "%b %d %T %Y %Z" as shown above (the script handles both). Install openssl via Homebrew if it’s missing.
For services not running on 443, adjust the connect string:
# SMTPS, IMAPS, or custom HTTPS ports
openssl s_client -connect mail.example.com:465 -servername mail.example.com 2>/dev/null \
| openssl x509 -noout -enddate
If you prefer Python, this version sends email alerts directly and handles connection failures gracefully:
#!/usr/bin/env python3
import ssl, socket, smtplib, datetime
from email.mime.text import MIMEText
DOMAINS = [
"example.com",
"api.example.com",
"staging.example.com",
]
WARN_DAYS = 30
ALERT_EMAIL = "ops@yourcompany.com"
SMTP_HOST = "localhost" # or your SMTP relay
def get_expiry(domain, port=443, timeout=10):
ctx = ssl.create_default_context()
try:
with socket.create_connection((domain, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
not_after = cert["notAfter"]
return datetime.datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
except Exception as e:
return None, str(e)
alerts = []
for domain in DOMAINS:
expiry = get_expiry(domain)
if expiry is None:
alerts.append(f"ERROR: Could not check {domain}")
continue
days_left = (expiry - datetime.datetime.utcnow()).days
if days_left < WARN_DAYS:
alerts.append(f"EXPIRING: {domain} — {days_left} days left (expires {expiry.date()})")
if alerts:
body = "\n".join(alerts)
msg = MIMEText(body)
msg["Subject"] = f"SSL Expiry Alert — {len(alerts)} domain(s) need attention"
msg["From"] = ALERT_EMAIL
msg["To"] = ALERT_EMAIL
with smtplib.SMTP(SMTP_HOST) as s:
s.send_message(msg)
print(f"Sent alert for {len(alerts)} domain(s)")
else:
print("All certificates OK")
If you don’t want to manage a cron server, GitHub Actions can run this check on a schedule and notify you via email or Slack:
# .github/workflows/ssl-check.yml
name: SSL Certificate Check
on:
schedule:
- cron: '0 8 * * *' # daily at 8am UTC
workflow_dispatch: # allow manual trigger
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check certificates
run: |
WARN_DAYS=30
DOMAINS="example.com api.example.com staging.example.com"
FAILED=0
for domain in $DOMAINS; do
expiry=$(echo | openssl s_client -connect ${domain}:443 \
-servername $domain 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
days=$(( ($(date -d "$expiry" +%s) - $(date +%s)) / 86400 ))
echo "$domain: $days days remaining"
if [ "$days" -lt "$WARN_DAYS" ]; then
echo "::warning::$domain expires in $days days"
FAILED=1
fi
done
exit $FAILED
GitHub will send you an email when the workflow fails. For Slack notifications, add a step using slackapi/slack-github-action after the check step.
When checking 50+ domains, running checks sequentially is slow. Use xargs for parallelism:
# Check 20 domains in parallel, show any expiring within 60 days
cat domains.txt | xargs -P 20 -I{} sh -c '
expiry=$(echo | openssl s_client -connect "{}:443" -servername "{}" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
[ -z "$expiry" ] && echo "ERROR: {}" && exit
days=$(( ($(date -d "$expiry" +%s) - $(date +%s)) / 86400 ))
[ "$days" -lt 60 ] && echo "WARN: {} expires in $days days"
'
Where domains.txt is a newline-separated list of hostnames. This approach handles 50 domains in under 30 seconds on most networks.
curl has a less-known flag that fails the request if the certificate expires within a given number of days:
# Fail if certificate expires within 14 days
curl --cacert /etc/ssl/certs/ca-certificates.crt \
--max-time 10 \
--cert-status \
-o /dev/null -w "%{ssl_verify_result}" \
https://example.com
# Or use --connect-timeout + --cert-status in a loop
for domain in example.com api.example.com; do
code=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time 10 \
"https://${domain}/")
echo "$domain: HTTP $code"
done
While curl can tell you a certificate is valid, it doesn’t natively return days-until-expiry. Use the openssl approach above for expiry-day calculations.
Shell scripts and cron jobs work well up to a point. Past 20–30 domains, the friction compounds:
*.example.com covers subdomains but a naive script checking example.com won’t surface all affected subdomains when the cert expires.The standard failure pattern: a team sets up Let’s Encrypt auto-renewal, it works for a year, then certbot fails silently (often because a DNS record changed, or a firewall rule blocked port 80 during ACME challenge). Nobody gets an alert. The certificate expires at 3am on a Saturday. A customer reports the outage Monday morning.
This isn’t rare. It’s the norm for teams without dedicated monitoring.
If you manage enough domains that DIY scripts are painful, dedicated tools reduce the operational overhead:
| Tool | Best for | Alert channels | Price |
|---|---|---|---|
| SSLWatch | Developers monitoring 5–50 domains with email alerts at 30/14/7/1 days | $9/mo | |
| UptimeRobot | Teams already using uptime monitoring who want cert checks bundled | Email, Slack, SMS | Free tier (5-min intervals on paid plans) |
| Datadog Synthetics | Enterprises with existing Datadog instrumentation | PagerDuty, Slack, email | $5+/test/mo |
| StatusCake | Agencies managing client domains | Email, Slack, webhooks | Free tier available |
| Nagios/Zabbix | Self-hosted ops teams with existing infrastructure monitoring | Configurable | Free (self-hosted) |
The tradeoffs are mostly about overhead: Nagios gives you full control but requires infrastructure to run and maintain. A SaaS tool requires zero infrastructure and starts monitoring in minutes.
A complete SSL certificate monitoring solution for multiple domains needs:
If you’re using Let’s Encrypt with certbot or ACME, auto-renewal handles most cases — but monitoring is the fallback that catches the cases where auto-renewal silently fails. Don’t skip it.
SSLWatch checks your SSL certificates daily and sends email alerts at 30, 14, 7, and 1 day before expiry. Monitor up to 50 domains for $9/month — no infrastructure required.
# Single domain — days remaining
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -enddate
# Multiple domains from file — parallel (requires GNU xargs)
cat domains.txt | xargs -P 10 -I{} sh -c \
'echo "{}: $(echo | openssl s_client -connect {}:443 -servername {} 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)"'
# Check via curl (verify only, no expiry days)
curl -vI --max-time 10 https://example.com 2>&1 | grep -E "expire|SSL|TLS"
# Via Python one-liner
python3 -c "
import ssl, socket, datetime
ctx = ssl.create_default_context()
with socket.create_connection(('example.com', 443), 5) as s:
with ctx.wrap_socket(s, server_hostname='example.com') as ss:
e = ss.getpeercert()['notAfter']
exp = datetime.datetime.strptime(e, '%b %d %H:%M:%S %Y %Z')
print(f'Expires: {exp.date()} ({(exp - datetime.datetime.utcnow()).days} days)')"