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:
-
openssl s_clientcan fetch a live server certificate chain fromhost:443. -
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 opensslbash-
curl(optional, for webhook alerts)
Install on Debian/Ubuntu:
sudo apt update
sudo apt install -y openssl curl
Step 1) Create a domain inventory
Create /etc/tls-monitor/domains.txt:
example.com
api.example.com
status.example.net
One FQDN per line. Keep it simple.
sudo install -d -m 0755 /etc/tls-monitor
sudoedit /etc/tls-monitor/domains.txt
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"
Set permissions:
sudo chmod 0755 /usr/local/bin/tls-expiry-check.sh
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
/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
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now tls-expiry-check.timer
Validate:
systemctl list-timers --all | grep tls-expiry-check
systemctl status tls-expiry-check.timer
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
Temporary aggressive threshold test (warn on certs expiring within 400 days):
sudo systemctl edit tls-expiry-check.service
Drop-in:
[Service]
Environment=WARN_DAYS=400
Then:
sudo systemctl daemon-reload
sudo systemctl start tls-expiry-check.service
sudo journalctl -u tls-expiry-check.service -n 100 --no-pager
Revert the override after testing:
sudo systemctl revert tls-expiry-check.service
sudo systemctl daemon-reload
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/*.envand load viaEnvironmentFile=. - 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
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
- OpenSSL
x509manual (-checkend): https://docs.openssl.org/3.2/man1/openssl-x509/ - OpenSSL
s_clientmanual (-connect,-servername): https://docs.openssl.org/3.4/man1/openssl-s_client/ -
systemd.timer(5): https://man7.org/linux/man-pages/man5/systemd.timer.5.html - ArchWiki,
Persistent=truetimer behavior example: https://wiki.archlinux.org/title/Systemd/Timers - curl TLS verification behavior: https://curl.se/docs/sslcerts.html
- Debian
update-ca-certificates(8): https://manpages.debian.org/testing/ca-certificates/update-ca-certificates.8.en.html
Top comments (0)