Tutorial

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.

·
11 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

How to Monitor Client Domains and SSL Certificates for Free Before They Expire

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<br/>GitHub Actions daily<br/>VPS cron for uptime"] --> C["Check script"]
    L["clients.yml<br/>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<br/>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.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 <TOKEN> with your token:

    https://api.telegram.org/bot<TOKEN>/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.

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:

# ---------- 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:

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:

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 <your-private-repo> /opt/client-watchdog
cd /opt/client-watchdog

Create /opt/client-watchdog/.env with your secrets and lock it down:

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:

*/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:

- 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.

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Sanju Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Sanju Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.