
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.xyzList every hostname that is actually served. The apex,
wwwandapp.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
expectto 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
/newbotand 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
<TOKEN>with your token:https://api.telegram.org/bot<TOKEN>/getUpdatesFind
"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 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
expirationevent. Some country-code TLDs have no RDAP server, so for those, setdomain_expiresby 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 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 <your-private-repo> /opt/client-watchdog
cd /opt/client-watchdogCreate /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 .envThen 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>&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_URLsecret in GitHub and put the second in the VPS.envfile.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_expiresis 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
.envfile, never inclients.ymlor 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 |
Works locally but fails in GitHub Actions | Secret names match exactly, |
"No expiry date found" for a domain | The TLD has no RDAP server, so add |
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 |
|
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!