Automated SSL/TLS Certificate Expiry Monitor with Python
Expired SSL/TLS certificates can interrupt web and API services. This guide builds a daily monitor that reads the serverâs leaf certificate to calculate its expiry date, then makes a separate normal TLS connection to check certificate-chain and hostname validation.
The expiry inspection deliberately disables certificate validation only for the metadata-only probe so it can read an expired certificate. Never send credentials or application data through that probe. The separate verified handshake reports whether a client that uses Pythonâs default trust store would accept the endpoint.
Step 1: Create an Isolated Environment and Service Account
Install the Certificate Parser in a Virtual Environment
SetupThe standard library provides the TLS connection, while the cryptography package parses the returned DER certificate. Use Python 3.10 or newer and a dedicated system account for the scheduled service.
sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin certmonsudo install -d -o root -g root -m 0755 /opt/cert-monitorsudo python3 -m venv /opt/cert-monitor/venvsudo /opt/cert-monitor/venv/bin/python -m pip install "cryptography>=42"⯠View Expected Console Output
Created service account certmon and installed cryptography in /opt/cert-monitor/venv.Step 2: Inspect Expiry and TLS Trust Separately
Build the Asynchronous Certificate Inspector
Python ScriptOpen /opt/cert-monitor/cert_checker.py with sudoedit and save this script there. Replace the example host with your endpoints. Each connection has a handshake timeout and an overall timeout. DNS resolution can return IPv4 or IPv6 addresses through asyncio.open_connection.
#!/usr/bin/env python3import asyncioimport sslfrom datetime import datetime, timezone
from cryptography import x509
TARGETS = [ ("example.com", 443),]WARNING_DAYS = 14CONNECT_TIMEOUT_SECONDS = 10HANDSHAKE_TIMEOUT_SECONDS = 5
async def read_leaf_certificate(host, port, context): reader, writer = await asyncio.open_connection( host=host, port=port, ssl=context, server_hostname=host, ssl_handshake_timeout=HANDSHAKE_TIMEOUT_SECONDS, ) try: tls_socket = writer.get_extra_info("ssl_object") if tls_socket is None: raise RuntimeError("TLS connection did not expose an SSL object") certificate_der = tls_socket.getpeercert(binary_form=True) if not certificate_der: raise RuntimeError("The endpoint did not present a certificate") return certificate_der finally: writer.close() try: await writer.wait_closed() except (OSError, ssl.SSLError): pass
async def inspect_certificate(host, port): metadata_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) metadata_context.check_hostname = False metadata_context.verify_mode = ssl.CERT_NONE
try: certificate_der = await asyncio.wait_for( read_leaf_certificate(host, port, metadata_context), timeout=CONNECT_TIMEOUT_SECONDS, ) certificate = x509.load_der_x509_certificate(certificate_der) expires_at = certificate.not_valid_after_utc except Exception as error: return { "host": host, "port": port, "status": "FAILED", "error": str(error), }
now = datetime.now(timezone.utc) days_remaining = int((expires_at - now).total_seconds() // 86400) if days_remaining < 0: expiry_status = "EXPIRED" elif days_remaining <= WARNING_DAYS: expiry_status = "EXPIRING" else: expiry_status = "HEALTHY"
trusted_context = ssl.create_default_context() try: await asyncio.wait_for( read_leaf_certificate(host, port, trusted_context), timeout=CONNECT_TIMEOUT_SECONDS, ) tls_validation = "VALID" validation_error = None except Exception as error: tls_validation = "INVALID" validation_error = str(error)
overall_status = ( "HEALTHY" if expiry_status == "HEALTHY" and tls_validation == "VALID" else "ALERT" ) return { "host": host, "port": port, "expires_on": expires_at.date().isoformat(), "days_remaining": days_remaining, "expiry_status": expiry_status, "tls_validation": tls_validation, "validation_error": validation_error, "status": overall_status, }
async def main(): results = await asyncio.gather( *(inspect_certificate(host, port) for host, port in TARGETS) ) for result in results: print( f"{result['host']}:{result['port']} " f"status={result['status']} " f"expiry={result.get('expiry_status', 'UNKNOWN')} " f"days={result.get('days_remaining', 'N/A')} " f"tls={result.get('tls_validation', 'UNKNOWN')}" )
if __name__ == "__main__": asyncio.run(main())⯠View Expected Console Output
example.com:443 status=HEALTHY expiry=HEALTHY days=120 tls=VALIDStep 3: Send Discord Alerts for Expiry or Trust Problems
Wire the Monitor to a Discord Webhook
Alert IntegrationStore the webhook URL in DISCORD_WEBHOOK_URL; do not put it in the script or source control. Add the imports and function below above main(), then replace the existing main() and execution guard with the second block so the alert function is called.
import jsonimport osimport urllib.requestfrom urllib.error import URLError
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
def send_alert(alert_items): if not alert_items: return if not DISCORD_WEBHOOK_URL: print("DISCORD_WEBHOOK_URL is unset; alert was not sent.") return
lines = [ f"- {item['host']}:{item['port']} " f"expiry={item.get('expiry_status', 'FAILED')} " f"days={item.get('days_remaining', 'N/A')} " f"TLS={item.get('tls_validation', 'UNKNOWN')}" for item in alert_items ] payload = json.dumps({ "content": "**SSL/TLS monitor alert**\n" + "\n".join(lines) }).encode("utf-8") request = urllib.request.Request( DISCORD_WEBHOOK_URL, data=payload, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=10) as response: response.read() print(f"Sent Discord alert for {len(alert_items)} endpoint(s).") except (OSError, URLError) as error: print(f"Discord webhook request failed: {error}")async def main(): results = await asyncio.gather( *(inspect_certificate(host, port) for host, port in TARGETS) ) alerts = [] for result in results: print( f"{result['host']}:{result['port']} " f"status={result['status']} " f"expiry={result.get('expiry_status', 'UNKNOWN')} " f"days={result.get('days_remaining', 'N/A')} " f"tls={result.get('tls_validation', 'UNKNOWN')}" ) if result["status"] != "HEALTHY": alerts.append(result)
send_alert(alerts)
if __name__ == "__main__": asyncio.run(main())⯠View Expected Console Output
Sent Discord alert for 1 endpoint(s).Step 4: Schedule a Daily Systemd Timer
Configure a Restricted Daily Service
SchedulingCreate a root-owned environment file for the webhook secret. The service runs as the unprivileged certmon account and writes its output to the systemd journal.
sudo install -o root -g root -m 0600 /dev/null /etc/cert-monitor.envsudoedit /etc/cert-monitor.env# Add this line in the editor:DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/replace-with-your-secretsudo tee /etc/systemd/system/cert-monitor.service >/dev/null <<'EOF'[Unit]Description=HTTPS certificate expiry and trust monitorWants=network-online.targetAfter=network-online.target
[Service]Type=oneshotUser=certmonGroup=certmonEnvironmentFile=/etc/cert-monitor.envExecStart=/opt/cert-monitor/venv/bin/python /opt/cert-monitor/cert_checker.pyNoNewPrivileges=truePrivateTmp=trueProtectSystem=strictProtectHome=trueEOF
sudo tee /etc/systemd/system/cert-monitor.timer >/dev/null <<'EOF'[Unit]Description=Run the certificate monitor daily
[Timer]OnCalendar=*-*-* 08:00:00Persistent=trueRandomizedDelaySec=5m
[Install]WantedBy=timers.targetEOF
sudo systemctl daemon-reloadsudo systemctl enable --now cert-monitor.timersudo systemctl start cert-monitor.servicesudo journalctl -u cert-monitor.service -n 20 --no-pagersudo systemctl list-timers cert-monitor.timer⯠View Expected Console Output
NEXT LEFT LAST PASSED UNITMon 2026-10-05 08:00:00 CEST ...