{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"how-to-monitor-client-domains-and-ssl-certificates-for-free-before-they-expire-a4pfs","url":"https://zyvop.com/how-to-monitor-client-domains-and-ssl-certificates-for-free-before-they-expire-a4pfs","title":"How to Monitor Client Domains and SSL Certificates for Free Before They Expire","subtitle":"Free daily checks for domain expiry, SSL expiry and downtime across all your client sites, with Telegram alerts and a heartbeat for the monitor itself.","tldr":"Clients blame you when a domain or certificate expires, even if it was never your job to renew it. This post builds a free monitor that checks every client site daily, alerts you on Telegram well before expiry, and tells you if the monitor itself stops running.","keywords":["monitoring","GitHub Actions","Freelancing","SSL","DevOps","Tutorial"],"entities":["Sanju Singh","monitoring","GitHub Actions","Freelancing","SSL","DevOps","Tutorial","ZyVOP"],"keyTakeaways":["Picture this: you built a website for a client two years ago, and it has run quietly ever since.","On a Friday evening they message you: \"The site is down, and Chrome says it's not secure.\" The SSL certificate expired yesterday.","Or it is worse: the domain lapsed, and a parked page now sits where your client's business used to be."],"headings":["What we are building","Step 1: List your clients","Step 2: Create the Telegram bot","Step 3: Write the three checks","Step 4: Decide when to alert, and send the message","Step 5: Schedule it","Daily expiry checks with GitHub Actions","Frequent uptime checks on a VPS (optional)","Step 6: The mistake worth understanding","Step 7: Verify that it actually works","Limitations and security","Troubleshooting","Conclusion"],"outboundLinks":["https://healthchecks.io/","https://badssl.com/"],"contentText":"Picture this: you built a website for a client two years ago, and it has run quietly ever since. On a Friday evening they message you: \"The site is down, and Chrome says it's not secure.\" The SSL certificate expired yesterday. Or it is worse: the domain lapsed, and a parked page now sits where your client's business used to be. You may not even be the person who renews either one, but the client sees a broken site and thinks of you. You cannot stop a declined card or a renewal email that lands in spam. You can make sure you hear about it 30 days early instead of 30 hours late. This post builds a free monitor that checks every client site for domain expiry, SSL expiry and downtime, alerts you on Telegram, and tells you when the monitor itself has stopped working. What we are building flowchart LR S[\"Scheduler&lt;br/&gt;GitHub Actions daily&lt;br/&gt;VPS cron for uptime\"] --&gt; C[\"Check script\"] L[\"clients.yml&lt;br/&gt;private repo\"] --&gt; C C --&gt; T[\"SSL: days to expiry\"] C --&gt; D[\"RDAP: days to domain expiry\"] C --&gt; U[\"HTTP: status and content\"] T --&gt; A{\"Below threshold?\"} D --&gt; A U --&gt; A A -- yes --&gt; N[\"Telegram alert\"] C -. \"ping on every run\" .-&gt; H[\"Heartbeat monitor&lt;br/&gt;alerts if pings stop\"]A Python script reads a list of clients, runs the three checks and sends Telegram alerts. GitHub Actions runs the expiry checks daily, and an optional VPS cron job runs quick uptime checks. A heartbeat service emails you if the script ever stops reporting in. Everything fits inside free tiers. You need: A private GitHub repository, because the client list is confidential. A Telegram account. A free Healthchecks.io account. The free plan allows 20 checks, and this setup uses two. A self-hosted Uptime Kuma push monitor also works. Python 3 on your machine for testing. Optional: a VPS for frequent uptime checks. Without one, the daily expiry checks still deliver most of the value. Step 1: List your clients Create clients.yml in the repository root: clients: - name: Acme Traders domain: acmetraders.com # the registered domain, not a subdomain sites: - url: https://acmetraders.com expect: \"Acme Traders\" # text that must appear on the page - url: https://www.acmetraders.com - url: https://app.acmetraders.com/login expect: \"Sign in\" - name: Example Studio domain: examplestudio.xyz domain_expires: 2027-03-14 # optional hand-entered date, see Step 3 sites: - url: https://examplestudio.xyzList every hostname that is actually served. The apex, www and app. can each have their own certificate and expiry date. Write the registered domain explicitly. Guessing it from a hostname breaks on names like example.co.uk, so one explicit line per client is simpler and always correct. Use expect to catch fake successes. It flags a server that returns status 200 but serves an error page, a parked page or a blank screen. Step 2: Create the Telegram bot In Telegram, open a chat with @BotFather, send /newbot and copy the token it gives you. Open a chat with your new bot and send it any message, such as \"hello\". Open this URL in your browser, replacing &lt;TOKEN&gt; with your token: https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates Find \"chat\":{\"id\":123456789,...} in the response. That number is your chat ID. Two gotchas: a bot cannot message you until you message it first (an empty getUpdates means you skipped step 2), and group chat IDs are negative, so copy the minus sign. Treat the token like a password. If it leaks, send /revoke to BotFather. In your repository, open Settings, Secrets and variables, Actions and add two secrets: TELEGRAM_TOKEN and TELEGRAM_CHAT_ID. Step 3: Write the three checks Create monitor.py. This first part holds the imports and the checks. from __future__ import annotations import json import os import re import socket import ssl import sys import time import urllib.error import urllib.request from datetime import date, datetime, timezone from urllib.parse import urlencode, urlparse import yaml TIMEOUT = 15 HEADERS = {\"User-Agent\": \"client-watchdog/1.0\"} # ---------- Check 1: SSL certificate expiry ---------- def ssl_days_left(host: str) -&gt; int: \"\"\"Days until the certificate served for `host` expires.\"\"\" ctx = ssl.create_default_context() with socket.create_connection((host, 443), timeout=TIMEOUT) as sock: # server_hostname sends SNI, so we get the certificate for this exact name with ctx.wrap_socket(sock, server_hostname=host) as tls: cert = tls.getpeercert() expires = ssl.cert_time_to_seconds(cert[\"notAfter\"]) return int((expires - time.time()) // 86400) # ---------- Check 2: domain expiry via RDAP ---------- def get_json(url: str): headers = {**HEADERS, \"Accept\": \"application/rdap+json, application/json\"} req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: return json.load(resp) _bootstrap = None def rdap_base_url(tld: str) -&gt; str | None: \"\"\"Ask IANA which RDAP server is responsible for this TLD.\"\"\" global _bootstrap if _bootstrap is None: _bootstrap = get_json(\"https://data.iana.org/rdap/dns.json\") for tlds, urls in _bootstrap[\"services\"]: if tld in tlds: return next((u for u in urls if u.startswith(\"https://\")), None) return None def domain_days_left(client: dict) -&gt; int | None: \"\"\"Days until the domain expires, or None if we cannot tell.\"\"\" manual = client.get(\"domain_expires\") # hand-entered fallback or override if manual: return (date.fromisoformat(str(manual)) - datetime.now(timezone.utc).date()).days domain = client[\"domain\"].lower() base = rdap_base_url(domain.rsplit(\".\", 1)[-1]) if base is None: return None # this TLD has no RDAP server data = get_json(f\"{base}domain/{domain}\") for event in data.get(\"events\", []): if event.get(\"eventAction\") == \"expiration\": stamp = re.sub(r\"\\.\\d+\", \"\", event[\"eventDate\"]).replace(\"Z\", \"+00:00\") expires = datetime.fromisoformat(stamp) if expires.tzinfo is None: expires = expires.replace(tzinfo=timezone.utc) return (expires - datetime.now(timezone.utc)).days return None # ---------- Check 3: is the site up and showing the right content? ---------- def check_http(url: str, expect: str | None = None) -&gt; str | None: \"\"\"Return a short problem description, or None if the site looks healthy.\"\"\" problem = \"unknown error\" for attempt in range(2): # one retry stops a single network blip from paging you try: req = urllib.request.Request(url, headers=HEADERS) with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: body = resp.read(500_000).decode(\"utf-8\", errors=\"replace\") if expect is None or expect in body: return None problem = f'page loaded but \"{expect}\" was not found' except urllib.error.HTTPError as e: problem = f\"HTTP {e.code}\" except Exception as e: # DNS failure, timeout, TLS error, and so on problem = f\"{type(e).__name__}: {e}\" if attempt == 0: time.sleep(5) return problemNotes on the choices: SSL. Python verifies the certificate during the handshake, so an already-expired certificate raises an error instead of returning a date. Step 4 handles that case. Domain expiry. RDAP is the structured replacement for WHOIS. The script asks IANA's public bootstrap file which server handles the TLD, then reads the expiration event. Some country-code TLDs have no RDAP server, so for those, set domain_expires by hand (as an unquoted date). Uptime. One retry after five seconds means a single dropped packet does not become a 3 a.m. alert. Step 4: Decide when to alert, and send the message The alert rule is deliberately stateless: alert at exactly 30, 14 and 7 days, then every day once three or fewer days remain. There is no \"already alerted\" file to corrupt, and the daily reminders at the end give you more than one chance if a run is ever skipped. Add the rest of the script below the checks: # ---------- Alert rules ---------- def should_alert(days_left: int) -&gt; bool: \"\"\"Alert at 30, 14 and 7 days, then every day once 3 or fewer remain.\"\"\" return days_left in (30, 14, 7) or days_left &lt;= 3 def when(days: int) -&gt; str: return f\"expired {-days} days ago\" if days &lt; 0 else f\"expires in {days} days\" def expiry_messages(client: dict) -&gt; list[str]: name = client[\"name\"] msgs: list[str] = [] # Domain try: days = domain_days_left(client) if days is None: msgs.append( f\"[{name}] No expiry date found for {client['domain']}. \" \"Add domain_expires to clients.yml.\" ) elif should_alert(days): msgs.append(f\"[{name}] Domain {client['domain']} {when(days)}\") except Exception as e: msgs.append(f\"[{name}] Domain lookup failed for {client['domain']}: {e}\") # SSL, once per HTTPS hostname hosts = sorted( {urlparse(s[\"url\"]).hostname for s in client[\"sites\"] if s[\"url\"].startswith(\"https://\")} ) for host in hosts: try: days = ssl_days_left(host) except ssl.SSLCertVerificationError as e: # expired, wrong host, bad chain reason = getattr(e, \"verify_message\", None) or str(e) msgs.append(f\"[{name}] SSL for {host} is invalid: {reason}\") continue except OSError as e: msgs.append(f\"[{name}] SSL check for {host} could not connect: {e}\") continue if should_alert(days): msgs.append(f\"[{name}] SSL certificate for {host} {when(days)}\") return msgs def uptime_messages(client: dict, state: dict) -&gt; list[str]: \"\"\"Alert when a site goes down and when it recovers, not on every run.\"\"\" msgs: list[str] = [] for site in client[\"sites\"]: url = site[\"url\"] problem = check_http(url, site.get(\"expect\")) now = \"down\" if problem else \"up\" before = state.get(url, \"up\") if now == \"down\" and before == \"up\": msgs.append(f\"[{client['name']}] DOWN: {url} ({problem})\") elif now == \"up\" and before == \"down\": msgs.append(f\"[{client['name']}] RECOVERED: {url}\") state[url] = now return msgs # ---------- Telegram, state and heartbeat ---------- STATE_FILE = os.environ.get(\"STATE_FILE\", \"state.json\") def load_state() -&gt; dict: try: with open(STATE_FILE) as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} def save_state(state: dict) -&gt; None: with open(STATE_FILE, \"w\") as f: json.dump(state, f, indent=2) def send_telegram(text: str) -&gt; None: token = os.environ[\"TELEGRAM_TOKEN\"] chat_id = os.environ[\"TELEGRAM_CHAT_ID\"] data = urlencode({\"chat_id\": chat_id, \"text\": text[:4000]}).encode() url = f\"https://api.telegram.org/bot{token}/sendMessage\" urllib.request.urlopen(url, data=data, timeout=TIMEOUT).read() def ping_heartbeat() -&gt; None: url = os.environ.get(\"HEARTBEAT_URL\") if url: # skipped while you test locally urllib.request.urlopen( urllib.request.Request(url, headers=HEADERS), timeout=TIMEOUT ).read() # ---------- Main ---------- def main() -&gt; None: mode = sys.argv[1] if len(sys.argv) &gt; 1 else \"expiry\" if mode not in (\"expiry\", \"uptime\"): sys.exit(\"usage: python monitor.py [expiry|uptime]\") with open(\"clients.yml\") as f: clients = yaml.safe_load(f)[\"clients\"] state = load_state() if mode == \"uptime\" else {} messages: list[str] = [] for client in clients: if mode == \"expiry\": messages += expiry_messages(client) else: messages += uptime_messages(client, state) if messages: send_telegram(\"Client watchdog\\n\\n\" + \"\\n\".join(f\"- {m}\" for m in messages)) if mode == \"uptime\": save_state(state) # saved only after the alert went out print(\"\\n\".join(messages) or \"All checks passed\") ping_heartbeat() # last line: reached only if everything above worked if __name__ == \"__main__\": main()Two ordering details matter: The uptime state is saved after the Telegram message goes out. If sending fails, the next run alerts again instead of silently swallowing the outage. ping_heartbeat() is the very last line, so if anything above it crashes, the ping never happens. Step 6 explains why that is the point. Test it on your machine before scheduling anything: pip install pyyaml export TELEGRAM_TOKEN=\"your-token\" export TELEGRAM_CHAT_ID=\"your-chat-id\" python3 monitor.py expiryIf everything is healthy, it prints All checks passed and sends nothing. Step 7 forces an alert. Step 5: Schedule it Daily expiry checks with GitHub Actions Create .github/workflows/watchdog.yml: name: client-watchdog on: schedule: - cron: \"30 3 * * *\" # once a day, in UTC workflow_dispatch: # adds a \"Run workflow\" button for manual runs permissions: contents: read jobs: expiry: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: python-version: \"3.12\" - run: pip install pyyaml - run: python monitor.py expiry env: TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} HEARTBEAT_URL: ${{ secrets.HEARTBEAT_URL }}Action versions change over time, so check each action's GitHub page for the current major version before you copy this. Three things to know about scheduled workflows: They can run late. GitHub's scheduler is best-effort, so never depend on an exact minute. Public repositories get paused. GitHub disables scheduled workflows in a public repository after 60 days with no repository activity. That is one more reason to keep this repository private. Private repositories use your plan's free minutes. The Free plan has included 2,000 a month, and each run is rounded up to a full minute, so a daily job costs about 30. Check your plan's current allowance. Do not run five-minute uptime checks on Actions. About 288 runs a day would burn through a private repository's free minutes quickly. Use a VPS for that part. Frequent uptime checks on a VPS (optional) On your VPS: sudo apt install python3-yaml git clone &lt;your-private-repo&gt; /opt/client-watchdog cd /opt/client-watchdogCreate /opt/client-watchdog/.env with your secrets and lock it down: cat &gt; .env &lt;&lt;'EOF' TELEGRAM_TOKEN=your-token TELEGRAM_CHAT_ID=your-chat-id HEARTBEAT_URL=https://hc-ping.com/your-uptime-check-id EOF chmod 600 .envThen add this line with crontab -e: */5 * * * * cd /opt/client-watchdog &amp;&amp; set -a &amp;&amp; . ./.env &amp;&amp; set +a &amp;&amp; /usr/bin/python3 monitor.py uptime &gt;&gt; watchdog.log 2&gt;&amp;1The state.json file lives in that folder, so an outage produces one DOWN message and one RECOVERED message, not one every five minutes. If a client's firewall blocks your checks, give them your VPS's public IP to allowlist. If you followed my earlier guide on getting a stable public IP with a VPS and Tailscale, you already have exactly the kind of stable address clients are happy to allowlist. Step 6: The mistake worth understanding Here is the failure that makes monitoring dangerous: a dead monitor looks exactly like a healthy one. If the workflow is disabled, the Telegram token is revoked, the VPS is rebuilt or the cron entry disappears, the alerts just stop. Silence feels like good news, so you assume every client is fine. The fix is a dead man's switch: the script reports in every time it finishes, and a separate service raises the alarm when the reports stop. Create a free Healthchecks.io account. Create a check for the daily expiry job: period 1 day, grace time at least a couple of hours, because scheduled runs can be late. Create a second check for the VPS uptime job: period 5 minutes, grace about 10 minutes. Save the first ping URL as the HEARTBEAT_URL secret in GitHub and put the second in the VPS .env file. Set the notification channel to email, not Telegram. That last choice is deliberate. If your Telegram setup is what broke, an alert about it sent over Telegram would vanish too, so the watchdog's watchdog needs a different route. Step 7: Verify that it actually works Never trust an alert system you have not seen fire. Test each path on purpose. 1. Force an expiry alert. Temporarily change should_alert to return True, run python3 monitor.py expiry, and confirm Telegram lists every domain and certificate with its days remaining. Then revert it. 2. Check a broken certificate. Add this test client: - name: Test domain: example.com domain_expires: 2099-01-01 # skips the RDAP lookup for this test sites: - url: https://expired.badssl.com/You should get a message saying the certificate is invalid because it has expired. badssl.com hosts public test sites for exactly this. Remove the test client afterwards. 3. Check DOWN and RECOVERED. Add a site with an expect value that is not on the page, run python3 monitor.py uptime and confirm a DOWN message. Fix the text and run it again to get RECOVERED. A third run should send nothing. 4. Check the heartbeat. Create a throwaway Healthchecks check with a very short period, ping it once with curl, and wait. You should get an email saying the pings stopped. If not, fix your notification settings now, not during an outage. Finally, open the Actions tab, click Run workflow, and make sure the job succeeds from GitHub's environment, not just from your laptop. Limitations and security Cloudflare can hide an expiring origin certificate. Behind Cloudflare's proxy, your script sees Cloudflare's edge certificate, not the one on your origin server. In Full (strict) mode, an expired origin certificate shows up as an HTTP 526 error, which the uptime check catches, but only if the uptime check is running. You can warn about a domain but not renew it. It usually lives in the client's registrar account, so treat the alert as a prompt to forward, for example: \"Your domain expires in 14 days; please check that your renewal payment method is current.\" RDAP servers are not perfect. Registries can rate-limit or have outages. A failed lookup sends an alert saying so, and domain_expires is your fallback. Tell clients you are checking their site. Bot-protection tools can block frequent requests, and a heads-up makes allowlisting easy. Keep secrets out of the repository. Tokens belong in Actions secrets and the VPS .env file, never in clients.yml or the script. This is a safety net, not a monitoring suite. It does not detect defaced pages, changed DNS records or slow responses, and it checks from one location only. Troubleshooting Symptom What to check No Telegram message ever arrives Message the bot first, confirm the chat ID (negative for groups), and call the sendMessage URL by hand Works locally but fails in GitHub Actions Secret names match exactly, pip install pyyaml is present, and the job log shows the real error \"No expiry date found\" for a domain The TLD has no RDAP server, so add domain_expires (unquoted date) for that client SSL check passes but visitors see a certificate error The site is behind Cloudflare's proxy, so check the origin certificate and look for HTTP 526 Uptime alerts fire on every run state.json is not persisting, so check the working directory, permissions and STATE_FILE Heartbeat alert arrives but the script ran The grace period is too short for a delayed scheduled run, or the ping URL is wrong Conclusion You no longer have to wait for a client to tell you their site is broken. A small script, a private repository and a few free accounts tell you about expiring domains and certificates weeks ahead, and about outages within minutes. The part that matters most is the heartbeat, because it is what makes \"no news\" actually mean \"good news\". One final rule: an alert you have never seen fire is an alert you cannot trust. Break something on purpose, watch the message arrive, and only then relax.","contentHash":"sha256:f9defed1b4942c4383bad137c836592c8b65aa77546ae4ff5b6d5262bf0cbf3e","authorName":"Sanju Singh","authorUrl":"https://zyvop.com/author/sanjay687","authorSameAs":[],"category":"Tutorial","tags":["monitoring","GitHub Actions","Freelancing","SSL","DevOps"],"audience":"Senior software engineers, systems architects, and technical leads working with Tutorial","tone":"Instructional, practical, code-first","readingTimeMinutes":12,"wordCount":2990,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-11-02T04:50:05.336Z","updatedAt":"2026-11-02T04:50:05.336Z","canonicalUrl":"https://zyvop.com/how-to-monitor-client-domains-and-ssl-certificates-for-free-before-they-expire-a4pfs"}