← Blog  ·  October 2026

How to Monitor SSL Certificate Expiry Across Multiple Domains

Bash scripts, cron jobs, and automated tools that alert you before any cert expires.

The multi-domain problem: Checking one domain manually is easy. Keeping 10, 30, or 100 domains from quietly expiring is an ops problem that most teams solve badly — until a certificate expires on a Sunday night and a customer notices first.

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.


Option 1: A Simple Bash Script (5-50 Domains)

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
macOS note: The 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.

Checking non-standard ports

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

Option 2: Python Script with Email Alerts

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")

Option 3: GitHub Actions (Zero Infrastructure)

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.

Limitation: GitHub Actions only alerts you when the workflow fails. If the cron itself stops running (due to repo inactivity or a YAML syntax error), you won’t know. For critical production domains, pair this with a dedicated monitoring service.

Option 4: Using openssl with xargs for Speed

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.


Option 5: curl’s Built-in Certificate Check

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.


Where DIY Scripts Break Down

Shell scripts and cron jobs work well up to a point. Past 20–30 domains, the friction compounds:

The failure mode that matters

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.


Automated Certificate Monitoring Tools

If you manage enough domains that DIY scripts are painful, dedicated tools reduce the operational overhead:

ToolBest forAlert channelsPrice
SSLWatch Developers monitoring 5–50 domains with email alerts at 30/14/7/1 days Email $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.


What Multi-Domain Monitoring Actually Requires

A complete SSL certificate monitoring solution for multiple domains needs:

  1. Daily checks on every domain — not just a one-time scan
  2. Alerts at meaningful lead times — 30 days for planning, 14 days for urgency, 7 and 1 day as emergency escalation
  3. Reliable delivery — email alerts that actually land in your inbox, not spam
  4. Coverage for non-standard ports — internal services, mail servers, custom HTTPS endpoints
  5. Certificate chain validation — the leaf cert may be valid but an intermediate cert in the chain might not be

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.

Monitor all your domains without a cron server

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.

Check a domain free Get early access


Quick Reference: Commands for Multi-Domain SSL Checks

# 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)')"