--- title: "How to Monitor Client Domains and SSL Certificates for Free Before They Expire" author: "Sanju Singh" published: "2026-11-02T04:50:05.336Z" updated: "2026-11-02T04:50:05.336Z" url: "https://zyvop.com/how-to-monitor-client-domains-and-ssl-certificates-for-free-before-they-expire-a4pfs" canonical: "https://zyvop.com/how-to-monitor-client-domains-and-ssl-certificates-for-free-before-they-expire-a4pfs" tags: ["monitoring","GitHub Actions","Freelancing","SSL","DevOps"] --- # How to Monitor Client Domains and SSL Certificates for Free Before They Expire 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. ![Cover image]() 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 ```mermaid flowchart LR S["Scheduler
GitHub Actions daily
VPS cron for uptime"] --> C["Check script"] L["clients.yml
private repo"] --> C C --> T["SSL: days to expiry"] C --> D["RDAP: days to domain expiry"] C --> U["HTTP: status and content"] T --> A{"Below threshold?"} D --> A U --> A A -- yes --> N["Telegram alert"] C -. "ping on every run" .-> H["Heartbeat monitor
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: ```yaml 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.xyz ``` - **List 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 1. In Telegram, open a chat with **@BotFather**, send `/newbot` and copy the **token** it gives you. 2. Open a chat with your new bot and send it any message, such as "hello". 3. Open this URL in your browser, replacing `` with your token: ```text https://api.telegram.org/bot/getUpdates ``` 4. 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. ```python 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) -> 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) -> 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) -> 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) -> 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 problem ``` Notes 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: ```python # ---------- Alert rules ---------- def should_alert(days_left: int) -> 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 <= 3 def when(days: int) -> str: return f"expired {-days} days ago" if days < 0 else f"expires in {days} days" def expiry_messages(client: dict) -> 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) -> 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() -> dict: try: with open(STATE_FILE) as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} def save_state(state: dict) -> None: with open(STATE_FILE, "w") as f: json.dump(state, f, indent=2) def send_telegram(text: str) -> 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() -> 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() -> None: mode = sys.argv[1] if len(sys.argv) > 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: ```bash pip install pyyaml export TELEGRAM_TOKEN="your-token" export TELEGRAM_CHAT_ID="your-chat-id" python3 monitor.py expiry ``` If 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`: ```yaml 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: ```bash sudo apt install python3-yaml git clone /opt/client-watchdog cd /opt/client-watchdog ``` Create `/opt/client-watchdog/.env` with your secrets and lock it down: ```bash cat > .env <<'EOF' TELEGRAM_TOKEN=your-token TELEGRAM_CHAT_ID=your-chat-id HEARTBEAT_URL=https://hc-ping.com/your-uptime-check-id EOF chmod 600 .env ``` Then add this line with `crontab -e`: ```text */5 * * * * cd /opt/client-watchdog && set -a && . ./.env && set +a && /usr/bin/python3 monitor.py uptime >> watchdog.log 2>&1 ``` The `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. 1. Create a free Healthchecks.io account. 2. 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. 3. Create a second check for the VPS uptime job: period 5 minutes, grace about 10 minutes. 4. Save the first ping URL as the `HEARTBEAT_URL` secret in GitHub and put the second in the VPS `.env` file. 5. 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: ```yaml - 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.