certbot had been auto-renewing my certs for two years. Until it wasn't.

certbot had been auto-renewing my certs for two years. Until it wasn't.

Leader 1 5 28
calendar_today agoschedule5 min read

certbot had been running quietly on my server for almost two years without a single issue.

Automatic renewals. Silent cron job. I never thought about it. Set it up once, tested it once, and mentally moved it to the "solved" column of my infrastructure checklist. That column felt good. certbot goes in there and stays there.

Then I checked my site on a Monday morning and got the red browser warning.

Not "connection is slow." Not a timeout. The full-page block. Chrome's red lock icon. "Your connection is not private." NET::ERR_CERT_DATE_INVALID in small gray text underneath, in case you wanted the clinical version of bad news.

I SSHd in immediately. The cert had expired three days earlier.

certbot had been running, technically. The cron job was firing. No errors in /var/log/syslog that I was watching. But the HTTP challenge was failing silently — something to do with a port conflict during renewal. A service I'd added a few months back was binding to port 80 for its own health check, and certbot couldn't complete the ACME challenge because port 80 wasn't free during the brief window it needed it. The renewal attempt failed. certbot logged it somewhere I wasn't watching. The cron job reported success because the cron job ran — it just happened to run a certbot process that quietly gave up.

No alert email. No log message in any file I had on my radar. Just a quiet failure, three renewal cycles in a row, and then an expired certificate.

I found out from a user who emailed me. Not from monitoring. Not from a script. From a stranger who was kind enough to say "hey, your SSL is broken" instead of just closing the tab.

The renewal fix took five minutes once I found the port conflict. What I didn't have — what I wish I'd had — was a script that told me the cert was about to expire before it happened. Something running every morning, checking the actual live certificate the way a browser would check it, and reporting "you have 18 days left — go fix this."

Turns out openssl can do this in one command. It's been able to do this for years. I just never looked.


The Core Command

echo | openssl s_client -connect yoursite.com:443 -servername yoursite.com \
  2>/dev/null | openssl x509 -noout -enddate

Run that and you get back something like:

notAfter=Aug 14 12:00:00 2026 GMT

That's the raw expiry date straight from the live certificate your server is presenting to the world. Not what certbot thinks. Not what your local files say. What a browser actually sees when it connects.

To turn that into days remaining:

EXPIRY=$(echo | openssl s_client -connect yoursite.com:443 -servername yoursite.com \
  2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)

DAYS=$(( ($(date -d "$EXPIRY" +%s) - $(date +%s)) / 86400 ))

echo "$DAYS days remaining on SSL cert"

That arithmetic converts two Unix timestamps (the expiry date and now) to seconds, subtracts them, and divides by 86400 to get days. It's ugly but it works.


Three Non-Obvious Things I Ran Into

1. The -servername flag is not optional on SNI hosts.

SNI — Server Name Indication — is how a single IP address serves multiple domains with different SSL certificates. Most shared hosting and most modern VPS setups use it. Without -servername yoursite.com, openssl doesn't know which certificate to request. It reads the server's default certificate instead of yours.

I spent twenty minutes getting clean results from this command before I realized I was checking the wrong cert. My server's default certificate was still valid. My production domain's certificate was the one expiring. I would have gotten a false "everything is fine" reading and missed the problem entirely. Always include -servername.

2. The echo | pipe is required for non-interactive use.

Without it, openssl s_client waits for stdin input after establishing the connection. In a cron job, that means the script hangs forever, waiting for input that never comes, holding the connection open until something kills it. The echo sends empty input immediately, which closes the connection after the certificate handshake completes. This is one of those things where the command works perfectly in your terminal and completely silently breaks in cron. The echo | is what makes it safe to automate.

3. The date -d syntax is Linux-only.

macOS uses date -j -f "%b %d %T %Y %Z" "$EXPIRY" +%s instead of date -d "$EXPIRY" +%s. If you're building a script that needs to run on both platforms, you'll need a conditional. The full version of the script at the link below handles this with an OS check. If you're only ever running this on Linux servers — which is probably most of you — you don't need to care about this.


The Full Script

#!/bin/bash

CHECK="✓"
CROSS="✗"

# --- Configuration ---
DOMAINS=(
  "yourdomain.com"
  "anotherdomain.com"
)
WARN_DAYS=30      # Alert if fewer than this many days remain
PORT=443

# --- Check each domain ---
for DOMAIN in "${DOMAINS[@]}"; do
  EXPIRY=$(echo | openssl s_client \
    -connect "${DOMAIN}:${PORT}" \
    -servername "$DOMAIN" \
    2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)

  if [[ -z "$EXPIRY" ]]; then
    echo "$CROSS Could not retrieve cert for $DOMAIN — is the server reachable?"
    continue
  fi

  EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null)
  NOW_EPOCH=$(date +%s)
  DAYS=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

  if [[ "$DAYS" -lt 0 ]]; then
    echo "$CROSS $DOMAIN — CERT EXPIRED ${DAYS#-} days ago"
  elif [[ "$DAYS" -lt "$WARN_DAYS" ]]; then
    echo "$CROSS $DOMAIN — WARNING: $DAYS days remaining (expires $EXPIRY)"
  else
    echo "$CHECK $DOMAIN — OK: $DAYS days remaining"
  fi
done

Sample output with two domains:

✓ yourdomain.com — OK: 74 days remaining
✗ anotherdomain.com — WARNING: 11 days remaining (expires Sep 2 12:00:00 2026 GMT)

Why 30 Days as the Threshold

Let's Encrypt certificates are 90 days. certbot typically renews at 60 days remaining — 30 days before the default renewal window. If my monitoring fires at 30 days, I have a full renewal cycle's worth of time to figure out whatever certbot is failing on before anything breaks.

That's the math. 30 days is not conservative for its own sake — it's exactly one full renewal attempt window on a 90-day cert. If you're using a 1-year cert from a paid CA, you probably want 60 days. If you're on Let's Encrypt, 30 days gives you two full automated renewal attempts before you hit zero.


Adding an Email Alert

If you want to be notified instead of (or in addition to) just logging, add this to the warning block:

if [[ "$DAYS" -lt "$WARN_DAYS" ]]; then
  echo "SSL cert for $DOMAIN expires in $DAYS days" | \
    mail -s "CERT EXPIRY WARNING: $DOMAIN" *Emails are not allowed*
fi

This requires mailutils or sendmail to be configured on your server. If you don't have email set up, the cron log version is enough — as long as you actually read the log, which is its own challenge.


The Cron Setup

0 8 * * * /home/user/check-ssl-expiry.sh >> /var/log/ssl-check.log 2>&1

Every morning at 8am. Logs to /var/log/ssl-check.log. If something fires, the line is there when you check in the morning. If everything is green, the log file is a quiet record that your certs are healthy.

I run this across all my domains every day. 30-day warning threshold. On a Let's Encrypt setup that gives two full auto-renewal cycles before anything breaks. No more Monday morning surprises.

The cert that expired on me was a $0 certificate on a $5 server. The cost wasn't the problem. The embarrassment of a user telling me before my own monitoring did — that was the part I wasn't willing to repeat.


Full script with multi-domain array, configurable threshold, email alert variation, macOS compatibility, and cron setup instructions:

bashsnippets.xyz/snippets/check-ssl-certificate-expiry

bashsnippets.xyz

2k Points34 Badges1 5 28
North Americabashsnippets.xyz
19Posts
23Comments
3Followers
3Connections
Linux user who got tired of Googling the same bash commands every time I sat down at a terminal. Started writing them down. That turned into bashsnippets.xyz -->
a free and growi... Show more
Build your own developer journey
Track progress. Share learning. Stay consistent.
Part 6 of 6 in Bash Snippets Pages

1 Comment

2 votes
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Comparison: Universal Import vs. Plaid/Yodlee

Pocket Portfolio - Mar 12

My Nginx Died at 2 AM and Nobody Noticed for 6 Hours. Now I Have a Watchdog Script

BashSnippets - May 21

The Interface of Uncertainty: Designing Human-in-the-Loop

Pocket Portfolio - Mar 10

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!