The worst way to learn your website is down is from a customer: a support ticket, a social media post, a client asking why checkout is blank.

This post covers what an uptime check should look at, why one failure is not an outage, alert fatigue, cron jobs, and a script you can run today.

Why customers notice first

Your visitors use the site when you don't: at night, at weekends, from other countries and networks. Without a check of your own, they are your monitoring.

When you look, you see a friendlier version: a cached page, a logged-in session, a DNS answer your resolver remembers. Many failures are partial, too: checkout returns a 500 while the home page loads, or www works and the bare domain doesn't. Others come with a date: an expired certificate or domain.

Monitoring has a delay as well: the wait for the next check, a confirmation step, alert delivery, and the time until someone looks. With one-minute checks that is usually a few minutes, not seconds. No setup guarantees you hear first. Aim for a short, known delay and an alert in front of someone who can act.

What a real check should look at

"The server answered" is not "a visitor can use the site". Check each layer:

  • DNS. Does the name resolve, to the addresses you expect? A deleted record or a lapsed domain breaks everything below it.
  • TCP and TLS. Does the server accept connections, and does the certificate verify for this host name?
  • Certificate expiry. Warn weeks ahead. New public certificates last at most 200 days, and 100 from March 2027 (CA/Browser Forum), and Let's Encrypt stopped sending expiry emails in June 2025. If renewal breaks, your check may be the only warning.
  • Domain expiry. Registrar reminders go to whoever registered the domain, maybe someone who left years ago.
  • Redirects. Check where you land: still HTTPS, still your domain, not a loop. A redirect to a parked page ends in a 200.
  • Status and content. A 200 can still be an error page. Look for text that only appears when the page works, such as "Add to cart".
  • Response time. A page that takes 30 seconds is down for the person waiting. Use a timeout.

For the domain date, RDAP, the structured successor to WHOIS, answers in one line (rdap.org forwards the query to the right registry):

curl -sL https://rdap.org/domain/example.com | jq -r '.events[] | select(.eventAction == "expiration") | .eventDate' 

Some registries, such as .de and .eu, publish no date, so an empty answer means "unknown", not "fine".

A DIY check with curl

This script checks the status code, where redirects end, response time, an optional piece of text and the certificate's days left, all in one request. It needs bash and curl.

#!/usr/bin/env bash # Usage: ./check-site.sh URL ["text the page must contain"] # Exits 1 if anything needs a look, so cron can alert on it. export LC_ALL=C # English month names and dot decimals url="${1:?usage: check-site.sh URL [text]}" must_contain="${2:-}" max_seconds=5 # slower than this is a problem min_days=14 # warn below this many certificate days body=$(mktemp); trace=$(mktemp); trap 'rm -f "$body" "$trace"' EXIT problems=0 fail() { echo "PROBLEM: $*"; problems=1; } # 1. One request: status, timings, redirects, 10 s limit. # -v also logs the certificate of each TLS connection to $trace. fmt='%{http_code} %{time_namelookup} %{time_appconnect} %{time_total} %{num_redirects} %{url_effective}\n' read -r code dns tls total hops final < <( curl -sS -v -L --max-redirs 5 -m 10 -A 'check-site/1.0' -o "$body" -w "$fmt" "$url" 2>"$trace") echo "status=$code dns=${dns}s tls=${tls}s total=${total}s redirects=$hops final=$final" grep '^curl: (' "$trace" # curl's own error message, if any if [[ "$code" == 000 ]]; then fail "no response"; exit 1; fi [[ "$code" == 2* ]] || fail "status $code" [[ "$final" == https://* ]] || fail "ended on $final" awk -v t="$total" -v m="$max_seconds" 'BEGIN { exit !(t > m) }' && fail "slow: ${total}s" if [[ -n "$must_contain" ]] && ! grep -qF -- "$must_contain" "$body"; then fail "page does not contain \"$must_contain\"" fi # 2. Days left on the certificate of the last connection. end=$(sed -n 's/.*expire date: //p' "$trace" | tail -1) if [[ -z "$end" ]]; then fail "no certificate expiry found" else # GNU date first, then macOS/BSD date end_s=$(date -d "$end" +%s 2>/dev/null || date -j -f '%b %d %T %Y %Z' "$end" +%s) days=$(( (end_s - $(date +%s)) / 86400 )) echo "certificate: $days days left (until $end)" (( days >= min_days )) || fail "certificate has $days days left" fi exit $problems 

curl verifies the certificate chain and host name by default, so a broken certificate fails the request:

$ ./check-site.sh https://expired.badssl.com status=000 dns=0.042882s tls=0.000000s total=0.558530s redirects=0 final=https://expired.badssl.com/ curl: (60) SSL certificate problem: certificate has expired PROBLEM: no response 

The expiry date comes from curl's -v log, which most Linux and macOS builds print. openssl s_client works too, but it opens a second connection with no timeout of its own: in testing, a host with an unreachable IPv6 address stalled it for over a minute.

Run it from cron on a machine that doesn't host the site, and alert only on failure:

*/5 * * * * /usr/local/bin/check-site.sh https://example.com "Add to cart" > /tmp/check.log 2>&1 || mail -s "example.com failed its check" you@example.com < /tmp/check.log 

One failed check from one place is not an outage

The script checks from one place. A failed request only proves the path from the checker to the site failed: the checker's network, its DNS resolver, a firewall rate-limiting it, one unhealthy CDN node, a deploy restarting the server. Most of these are not an outage.

Three common filters:

  1. Retry from the same place a few seconds later. It removes blips, not a routing problem that outlasts the retries.
  2. Wait for several failures in a row. Less noise, more delay, still one point of view.
  3. Confirm from a second location with an independent network path. Both fail: the site is very likely down. One fails: a regional problem, worth reading but not worth waking someone for.

The trade-off: a strict two-location rule can hide an outage that only one region sees, so check from near your visitors and read the regional failures. For the script, re-running the check 30 seconds after a failure is a cheap filter. A real second location is the hard part to build yourself.

Alert fatigue is how real outages get missed

If every blip sends a message, people mute the channel, and the real outage lands where nobody reads. False alarms teach people to ignore alerts. A few rules:

  • Alert on confirmed failures, once per incident, plus one message on recovery.
  • Keep urgent and planned apart: "site down" goes to chat or a phone, "certificate expires in 14 days" to email or a ticket.
  • Pause alerts during planned maintenance.
  • Send alerts where someone looks, agree who acts, and send a test alert after every change.
  • Once a month, count the alerts that needed action. If few did, tune.

Cron jobs and backups: alert on silence

An outside check can't see a backup that stopped running. Cron fails quietly: the crontab didn't move to the new server, PATH differs under cron, the disk filled up. Nothing runs, so nothing errors.

A heartbeat turns this around. The job calls a URL when it succeeds, and the monitor alerts if the call doesn't arrive on schedule plus a grace time:

0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 https://example.com/heartbeat/abc123 > /dev/null 

&& pings only after a successful backup. -fsS fails on HTTP errors and stays quiet otherwise, -m 10 caps the time, --retry 3 rides out blips, and > /dev/null keeps cron from mailing you on success. Set the grace time above your slowest normal run.

Some services, Healthchecks.io among them, also accept the URL with /fail appended, so a failed run alerts without waiting out the grace time.

Your DIY checker is a cron job too, so give it a heartbeat. And a heartbeat only proves the job finished: test a restore now and then.

Free options, and when DIY is enough

  • cron + curl, as above: fine for a personal site or side project, if it runs away from the server it checks and you accept one location and no history.
  • Uptime Kuma: open source (MIT), self-hosted, for example in Docker. HTTP, keyword, DNS and TCP checks, push monitors that work as heartbeats, status pages, 90+ notification services. It checks from wherever you host it, and keeping it running is your job.
  • UptimeRobot free tier: hosted, 50 monitors every 5 minutes, HTTP, keyword, ping and port checks, commercial use allowed. Its pricing page lists SSL and domain expiry, heartbeats and multi-location checks as paid (November 2026).
  • Healthchecks.io: built for cron jobs, free for 20 jobs, open source (BSD) if you self-host.

DIY is enough when downtime costs you little and someone technical reads the alerts. Go hosted when the site sells something, needs confirmation from more than one place, or a client wants the history.

Whichever option you pick, set it up before the next outage. And if you found out about one in a memorable way, tell me in the comments.

This article was generated with AI. The commands and the script were tested before publishing.