Before you start

You need a Linux server with shell access and these tools. Most distributions ship all of them.

Before you start
RequirementWhyCheck with
BashThe script uses Bash syntax ([[ ]], +=)bash --version
OpenSSLConnects to each domain and reads the certificateopenssl version
GNU dateConverts the expiry date to seconds (date -d)date --version
curlSends the alert through the Forward Email APIcurl --version
sendmail (optional)Sends the alert through your local mail server instead of the APIcommand -v sendmail
Root accessDefault paths are /etc/ssl_monitor and /var/logsudo -v

macOS ships BSD date, which does not support date -d, so the script reports wrong day counts there unless you install GNU coreutils and change date to gdate.

To send alerts with the Forward Email API you need a Forward Email account on a paid plan, because outbound SMTP comes with all paid plans. The sender address must be an alias on a domain in your account, and the Email API docs ask you to set up DKIM, Return-Path and DMARC for that domain first.

1. Download the script

Save the script to /usr/local/bin and make it executable:

sudo curl -fsSL -o /usr/local/bin/ssl_monitor.sh \
  https://raw.githubusercontent.com/forwardemail/sslmonitor.com/main/ssl_monitor.sh
sudo chmod +x /usr/local/bin/ssl_monitor.sh

You can also copy the full script from the bottom of this page. Read it before you run it as root.

2. Set your email settings

Open the script and edit the configuration block at the top:

sudo nano /usr/local/bin/ssl_monitor.sh
2. Set your email settings
VariableDefaultWhat to set
EMAIL_TOadmin@yourdomain.comWhere alerts go. Separate several addresses with commas.
EMAIL_FROMssl-alerts@yourdomain.comSender address. With the API it must be an alias on your Forward Email domain.
FORWARD_EMAIL_API_KEYyour_api_key_hereYour API token from the Security page of your Forward Email account.
DOMAINS_FILE/etc/ssl_monitor/domains.txtThe list of domains to check.
WARNING_DAYS30A certificate with fewer days left than this is WARNING.
CRITICAL_DAYS7A certificate with fewer days left than this is CRITICAL.
LOG_FILE/var/log/ssl_monitor.logWhere results are appended.

The API token lets anyone who holds it use the Forward Email API as you, so keep the script readable by root only:

sudo chmod 700 /usr/local/bin/ssl_monitor.sh

If your certificates renew automatically, set WARNING_DAYS lower than the number of days left when your client renews, or you may get a warning on a normal renewal day. Certificate lifetimes has suggested values.

3. List your domains

Create the domains file with one hostname per line. Use the exact name the certificate is served for, such as www.example.com, without https:// or a port.

sudo mkdir -p /etc/ssl_monitor
sudo nano /etc/ssl_monitor/domains.txt
# Websites
example.com
www.example.com
# Mail server (must answer TLS on port 443)
mail.example.com

If the file does not exist, the script creates it on its first run with example.com and mail.example.com as samples and then checks those samples. Create your own file first to avoid a confusing first report.

The script always connects to port 443. A line such as example.com:8443 does not work.

4. Choose how alerts are sent

The script sends alerts through the Forward Email API by default. To use your server's sendmail command instead, swap which line is commented near the end of the script:

    # Uncomment one of these methods based on your preference:
    # send_email_api "$EMAIL_SUBJECT" "$HTML_REPORT"
    send_email_smtp "$EMAIL_SUBJECT" "$HTML_REPORT"

The sendmail method needs a local mail transfer agent (for example Postfix or msmtp) that can deliver mail. To relay it through Forward Email, point that agent at smtp.forwardemail.net on port 465 (SSL/TLS) or 587 (STARTTLS) with the full alias address as the username and its generated password, as listed in the Forward Email FAQ. Email alerts explains both methods in detail.

5. Test the script

Run it once by hand and read the log:

sudo /usr/local/bin/ssl_monitor.sh
sudo tail -n 20 /var/log/ssl_monitor.log

A healthy run logs lines like these:

Checking certificate for example.com...
example.com: OK - Expires: Jan 14 23:59:59 2027 GMT (87 days left)
No alerts needed, all certificates are valid

No email is sent when every domain is OK. To test email delivery, set WARNING_DAYS=400 for one run: every publicly trusted certificate has fewer than 400 days left, so each domain becomes WARNING and the script sends a report. Set it back afterwards.

With the API method the log says Alert email sent via API when Forward Email accepts the request and Failed to send alert email via API when it does not. curl prints the reason to the terminal.

6. Schedule it with cron

Open root's crontab:

sudo crontab -e

Add a line to run the check every day at 08:00 server time:

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 8 * * * /usr/local/bin/ssl_monitor.sh

The PATH line matters for the sendmail method: cron starts jobs with a short default PATH that often leaves out /usr/sbin, where sendmail lives. Once a day is enough for expiry checks. Each run with a problem sends a new report, so a certificate that stays in WARNING sends one email per day until you fix it.

7. Rotate the log

The script appends to its log on every run and never trims it. A small logrotate rule keeps it in check:

sudo tee /etc/logrotate.d/ssl_monitor > /dev/null <<'EOF'
/var/log/ssl_monitor.log {
    monthly
    rotate 6
    compress
    missingok
    notifempty
}
EOF

Run it without root

To run the script as a normal user, change DOMAINS_FILE and LOG_FILE to paths that user can write, create the directory for the domains file yourself, and change the mkdir -p /etc/ssl_monitor line to that directory. Then add the cron line with crontab -e as that user.

Update or remove

To update, download the script again and copy your settings back into the configuration block. To remove it, delete the cron line, then run:

sudo rm /usr/local/bin/ssl_monitor.sh /var/log/ssl_monitor.log
sudo rm -r /etc/ssl_monitor

Troubleshooting

Troubleshooting
SymptomLikely causeFix
ERROR with an empty expiry dateThe name does not resolve, port 443 is closed or blocked, or the server does not speak TLSRun the openssl s_client command from the command line guide for that domain and read the error
Days left look wrongBSD date (macOS) or a wrong system clockUse GNU date and check the clock with timedatectl
Failed to send alert email via APIWrong API token, no paid plan, or EMAIL_FROM is not an alias on your domainCheck the token on your account Security page and the sender alias
No email with the sendmail methodNo working mail transfer agent, or sendmail not on cron's PATHRun echo test | sendmail you@example.com by hand and add the PATH line to the crontab
The script hangs on one domainA firewall drops packets to port 443, so openssl waits for the TCP timeoutRemove the domain or fix the firewall

Full script

This is the current ssl_monitor.sh from the GitHub repository.

#!/bin/bash
# SSL Certificate Monitor Script
# This script monitors SSL certificate expiration dates and sends alerts via Forward Email
# Usage: Place in /usr/local/bin/ and add to crontab to run daily
# Example crontab entry: 0 8 * * * /usr/local/bin/ssl_monitor.sh

# Configuration
EMAIL_TO="admin@yourdomain.com"
EMAIL_FROM="ssl-alerts@yourdomain.com"
FORWARD_EMAIL_API_KEY="your_api_key_here" # Get from https://forwardemail.net/my-account/security
DOMAINS_FILE="/etc/ssl_monitor/domains.txt" # One domain per line
WARNING_DAYS=30 # Send warning when certificate expires in less than this many days
CRITICAL_DAYS=7 # Send critical alert when certificate expires in less than this many days
LOG_FILE="/var/log/ssl_monitor.log"

# Create config directory and domains file if they don't exist
mkdir -p /etc/ssl_monitor
if [ ! -f "$DOMAINS_FILE" ]; then
    echo "# Add domains to monitor, one per line" > "$DOMAINS_FILE"
    echo "example.com" >> "$DOMAINS_FILE"
    echo "mail.example.com" >> "$DOMAINS_FILE"
fi

# Function to send email via Forward Email HTTP API
send_email_api() {
    local subject="$1"
    local body="$2"

    local text
    # Plain text version of the report (HTML tags and blank lines removed)
    text=$(echo "$body" | sed -e 's/<[^>]*>//g' -e '/^[[:space:]]*$/d')

    # Form-encode every field so newlines and quotes in the report are sent safely
    if curl -sS --fail -X POST "https://api.forwardemail.net/v1/emails" \
      -u "$FORWARD_EMAIL_API_KEY:" \
      --data-urlencode "from=$EMAIL_FROM" \
      --data-urlencode "to=$EMAIL_TO" \
      --data-urlencode "subject=$subject" \
      --data-urlencode "html=$body" \
      --data-urlencode "text=$text" \
      -o /dev/null; then
        echo "Alert email sent via API at $(date)" >> "$LOG_FILE"
    else
        echo "Failed to send alert email via API at $(date)" >> "$LOG_FILE"
    fi
}

# Function to send email via sendmail (SMTP)
send_email_smtp() {
    local subject="$1"
    local body="$2"

    echo -e "Subject: $subject\nFrom: $EMAIL_FROM\nTo: $EMAIL_TO\nContent-Type: text/html\n\n$body" | \
    sendmail -t

    echo "Alert email sent via SMTP at $(date)" >> "$LOG_FILE"
}

# Function to check SSL certificate expiration
check_certificate() {
    local domain="$1"
    local expiry_date=""
    local days_left=0
    local status="OK"
    local error_message=""

    # Get certificate expiration date
    expiry_date=$(echo | openssl s_client -servername "$domain" -connect "$domain":443 2>/dev/null | \
                 openssl x509 -noout -enddate 2>/dev/null | \
                 sed -e 's/notAfter=//')

    if [ -z "$expiry_date" ]; then
        status="ERROR"
        error_message="Could not retrieve certificate for $domain"
        days_left=0
    else
        # Convert expiry date to seconds since epoch
        expiry_seconds=$(date -d "$expiry_date" +%s)
        current_seconds=$(date +%s)

        # Calculate days left
        seconds_left=$((expiry_seconds - current_seconds))
        days_left=$((seconds_left / 86400))

        # Determine status
        if [ $days_left -lt $CRITICAL_DAYS ]; then
            status="CRITICAL"
        elif [ $days_left -lt $WARNING_DAYS ]; then
            status="WARNING"
        fi
    fi

    # Return results
    echo "$domain|$expiry_date|$days_left|$status|$error_message"
}

# Initialize HTML report
HTML_REPORT="<h2>SSL Certificate Monitoring Report</h2>
<p><strong>Date:</strong> $(date)</p>
<p><strong>Server:</strong> $(hostname)</p>
<table border='1' cellpadding='5' cellspacing='0' style='border-collapse: collapse;'>
<tr style='background-color: #f2f2f2;'>
  <th>Domain</th>
  <th>Expiry Date</th>
  <th>Days Left</th>
  <th>Status</th>
</tr>"

# Check each domain
ALERT_NEEDED=false
while read -r domain || [ -n "$domain" ]; do
    # Skip comments and empty lines
    [[ "$domain" =~ ^#.*$ || -z "$domain" ]] && continue

    echo "Checking certificate for $domain..." >> "$LOG_FILE"

    # Get certificate info
    cert_info=$(check_certificate "$domain")

    # Parse results
    domain_name=$(echo "$cert_info" | cut -d'|' -f1)
    expiry_date=$(echo "$cert_info" | cut -d'|' -f2)
    days_left=$(echo "$cert_info" | cut -d'|' -f3)
    status=$(echo "$cert_info" | cut -d'|' -f4)
    error_message=$(echo "$cert_info" | cut -d'|' -f5)

    # Set row color based on status
    row_color="#ffffff" # Default white
    if [ "$status" = "WARNING" ]; then
        row_color="#fff3cd" # Light yellow
        ALERT_NEEDED=true
    elif [ "$status" = "CRITICAL" ]; then
        row_color="#f8d7da" # Light red
        ALERT_NEEDED=true
    elif [ "$status" = "ERROR" ]; then
        row_color="#f8d7da" # Light red
        ALERT_NEEDED=true
    fi

    # Add to HTML report
    HTML_REPORT+="<tr style='background-color: $row_color;'>
  <td>$domain_name</td>
  <td>$expiry_date</td>
  <td>$days_left</td>
  <td>$status</td>
</tr>"

    # Log results
    echo "$domain: $status - Expires: $expiry_date ($days_left days left)" >> "$LOG_FILE"

    if [ -n "$error_message" ]; then
        echo "  Error: $error_message" >> "$LOG_FILE"
    fi

done < "$DOMAINS_FILE"

# Complete HTML report
HTML_REPORT+="</table>
<p style='margin-top: 20px;'><em>This is an automated alert from your SSL certificate monitoring system.</em></p>
<p><strong>Note:</strong> It is recommended to renew certificates at least 14 days before expiration.</p>
<p><strong>Renewal Instructions:</strong></p>
<ol>
  <li>For Let's Encrypt certificates: Run <code>certbot renew</code></li>
  <li>For other certificates: Contact your certificate provider or generate a new CSR</li>
</ol>"

# Send alert if needed
if [ "$ALERT_NEEDED" = true ]; then
    EMAIL_SUBJECT="SSL Certificate Alert: $(hostname) - $(date +%Y-%m-%d)"

    # Uncomment one of these methods based on your preference:
    send_email_api "$EMAIL_SUBJECT" "$HTML_REPORT"
    # send_email_smtp "$EMAIL_SUBJECT" "$HTML_REPORT"

    echo "Alert sent due to expiring certificates" >> "$LOG_FILE"
else
    echo "No alerts needed, all certificates are valid" >> "$LOG_FILE"
fi

exit 0