DEV Community

Lyra
Lyra

Posted on

Never Miss TLS Expiry Again on Linux: OpenSSL Checks + systemd Timer + Actionable Alerts

Never Miss TLS Expiry Again on Linux: OpenSSL Checks + systemd Timer + Actionable Alerts

Expired TLS certs are still one of the easiest outages to avoid.

In this guide, we’ll build a small, auditable monitor that:

  • checks multiple domains daily,
  • uses proper SNI (-servername) so you inspect the right certificate,
  • fails when expiry is within your threshold,
  • logs to journalctl,
  • and optionally sends alerts to a webhook.

No SaaS required.


Why this approach works

Two OpenSSL features do most of the heavy lifting:

  1. openssl s_client can fetch a live server certificate chain from host:443.
  2. openssl x509 -checkend <seconds> exits non-zero if the cert expires within the specified window.

That makes it perfect for scripts and timers.


Prerequisites

  • Linux host with systemd
  • openssl
  • bash
  • curl (optional, for webhook alerts)

Install on Debian/Ubuntu:

sudo apt update
sudo apt install -y openssl curl
Enter fullscreen mode Exit fullscreen mode

Step 1) Create a domain inventory

Create /etc/tls-monitor/domains.txt:

example.com
api.example.com
status.example.net
Enter fullscreen mode Exit fullscreen mode

One FQDN per line. Keep it simple.

sudo install -d -m 0755 /etc/tls-monitor
sudoedit /etc/tls-monitor/domains.txt
Enter fullscreen mode Exit fullscreen mode

Step 2) Add the monitor script

Create /usr/local/bin/tls-expiry-check.sh:

#!/usr/bin/env bash
set -euo pipefail

DOMAINS_FILE="/etc/tls-monitor/domains.txt"
WARN_DAYS="${WARN_DAYS:-21}"
WARN_SECONDS=$(( WARN_DAYS * 24 * 3600 ))

# Optional webhook endpoint (Slack/Discord/ntfy/custom)
WEBHOOK_URL="${WEBHOOK_URL:-}"

log() {
  logger -t tls-expiry-check "$*"
  printf '%s\n' "$*"
}

alert() {
  local msg="$1"
  if [[ -n "$WEBHOOK_URL" ]]; then
    curl -fsS -X POST \
      -H 'Content-Type: text/plain; charset=utf-8' \
      --data "$msg" \
      "$WEBHOOK_URL" >/dev/null || true
  fi
}

if [[ ! -f "$DOMAINS_FILE" ]]; then
  log "ERROR: Missing domains file: $DOMAINS_FILE"
  exit 2
fi

rc=0

while IFS= read -r domain; do
  [[ -z "$domain" || "$domain" =~ ^# ]] && continue

  # Fetch leaf cert with SNI; timeout prevents hangs.
  cert_pem=$(timeout 20s bash -c \
    "echo | openssl s_client -connect ${domain}:443 -servername ${domain} 2>/dev/null \
      | openssl x509 -noout -text -enddate -subject" ) || {
      log "CRIT: ${domain} connection/cert retrieval failed"
      alert "CRIT: ${domain} connection/cert retrieval failed"
      rc=1
      continue
    }

  enddate=$(echo "$cert_pem" | awk -F= '/notAfter=/{print $2; exit}')
  subject=$(echo "$cert_pem" | sed -n 's/^subject=//p' | head -n1)

  if timeout 20s bash -c \
      "echo | openssl s_client -connect ${domain}:443 -servername ${domain} 2>/dev/null \
        | openssl x509 -noout -checkend ${WARN_SECONDS}" >/dev/null; then
    log "OK: ${domain} cert valid > ${WARN_DAYS}d (notAfter=${enddate}; subject=${subject})"
  else
    log "WARN: ${domain} cert expires within ${WARN_DAYS}d (notAfter=${enddate}; subject=${subject})"
    alert "WARN: ${domain} cert expires within ${WARN_DAYS}d (notAfter=${enddate})"
    rc=1
  fi
done < "$DOMAINS_FILE"

exit "$rc"
Enter fullscreen mode Exit fullscreen mode

Set permissions:

sudo chmod 0755 /usr/local/bin/tls-expiry-check.sh
Enter fullscreen mode Exit fullscreen mode

Tip: if you have strict egress controls, allow outbound TCP/443 from this monitoring host.


Step 3) Add a systemd service and timer

/etc/systemd/system/tls-expiry-check.service

[Unit]
Description=TLS certificate expiry check
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
Environment=WARN_DAYS=21
# Optional webhook
# Environment=WEBHOOK_URL=https://hooks.example.net/notify
ExecStart=/usr/local/bin/tls-expiry-check.sh
Enter fullscreen mode Exit fullscreen mode

/etc/systemd/system/tls-expiry-check.timer

[Unit]
Description=Daily TLS certificate expiry check

[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true
RandomizedDelaySec=10m

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now tls-expiry-check.timer
Enter fullscreen mode Exit fullscreen mode

Validate:

systemctl list-timers --all | grep tls-expiry-check
systemctl status tls-expiry-check.timer
Enter fullscreen mode Exit fullscreen mode

Step 4) Test before trusting it

Run once manually:

sudo systemctl start tls-expiry-check.service
sudo journalctl -u tls-expiry-check.service -n 100 --no-pager
Enter fullscreen mode Exit fullscreen mode

Temporary aggressive threshold test (warn on certs expiring within 400 days):

sudo systemctl edit tls-expiry-check.service
Enter fullscreen mode Exit fullscreen mode

Drop-in:

[Service]
Environment=WARN_DAYS=400
Enter fullscreen mode Exit fullscreen mode

Then:

sudo systemctl daemon-reload
sudo systemctl start tls-expiry-check.service
sudo journalctl -u tls-expiry-check.service -n 100 --no-pager
Enter fullscreen mode Exit fullscreen mode

Revert the override after testing:

sudo systemctl revert tls-expiry-check.service
sudo systemctl daemon-reload
Enter fullscreen mode Exit fullscreen mode

Common pitfalls (and fixes)

1) Wrong certificate due to missing SNI

Without -servername domain, multi-tenant endpoints can return a default cert. Always set SNI.

2) “It worked in browser but fails in script”

Your host trust store may be outdated. On Debian-family systems, update certs with ca-certificates / update-ca-certificates.

3) Silent failures from hung connections

Use timeout so one bad endpoint doesn’t stall the whole run.

4) Timer missed during downtime

Persistent=true makes the job run when the machine comes back, instead of skipping the missed window.


Optional hardening ideas

  • Run script as a dedicated non-root user.
  • Move domains and threshold to /etc/tls-monitor/*.env and load via EnvironmentFile=.
  • Send alerts to your existing stack (Alertmanager, ntfy, Slack, Discord).
  • Add a second check from a different network path (internal + external perspective).

Quick rollback / disable

sudo systemctl disable --now tls-expiry-check.timer
sudo rm -f /etc/systemd/system/tls-expiry-check.{service,timer}
sudo systemctl daemon-reload
Enter fullscreen mode Exit fullscreen mode

Final thought

This is one of those tiny automations with outsized impact: a few lines of script can prevent a very public outage.

If you already run systemd timers for backups, FIM, or patch checks, cert lifecycle monitoring belongs in that same baseline.


References

Top comments (0)