UptimeDuty.
← Blog

Your Backup Script Has Been Failing for Six Weeks and Nobody Noticed

Your Backup Script Has Been Failing for Six Weeks and Nobody Noticed

Here’s a failure mode almost everyone hits eventually. The nightly database backup starts throwing a permissions error. Cron dutifully runs it, the script exits non-zero, and the error goes to a mail spool nobody reads. Your website stays up. Your dashboard stays green. Six weeks later you need a restore, and the newest file in the bucket is from May.

Nothing was ever “down.” That’s exactly the problem.

A person working on code late at night

Why Uptime Monitors Miss This

An HTTP or TCP check is an inbound question: the monitor knocks, the service answers, you get a green tick. That model works because the thing being watched is always listening.

A cron job is the opposite. It isn’t listening for anything — it wakes up, does work, and exits. There’s no port to knock on, no endpoint to curl. If it never wakes up at all, there’s nothing to detect, because absence doesn’t send a request.

So the failure is invisible by default. The job that runs your backups, expires stale sessions, syncs an inventory feed, sends renewal reminders, or rotates logs can stop working entirely without a single alert firing.

The Four Ways a Scheduled Job Fails Quietly

  • It never ran. The server rebooted and cron didn’t come back, the container was rescheduled, a syntax error broke the crontab, or someone commented out a line “temporarily” in March.
  • It ran and crashed. Expired credentials, a full disk, a moved file path. The output goes to /dev/null or to local mail, which nobody has read since 2011.
  • It ran, exited 0, and did nothing. The script caught its own exception and swallowed it. The API returned an empty list and the loop had nothing to iterate over. Technically successful. Functionally useless.
  • It’s still running. The job that used to take four minutes now takes ninety, and each run overlaps the next until the box falls over.

A monitor that only watches your website tells you about none of these.

Heartbeat Monitoring: Invert the Check

The fix is to flip the direction. Instead of the monitor calling your job, your job calls the monitor. Each run ends with a quick HTTP request to a unique URL — a heartbeat, or ping.

The monitoring service then watches the clock instead of the service. You tell it the job should check in every day at 3 AM. If a heartbeat arrives, the run is recorded and nothing happens. If the window passes with no heartbeat, you get alerted.

This is a dead man’s switch, and it’s the only pattern that catches “it never ran at all,” because silence itself becomes the signal.

Rows of servers in a data center

Adding It to a Job You Already Have

The whole change is one line at the end of the job. In a crontab, the important detail is && rather than ; — you only want to ping when the job actually succeeded:

0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 "$HEARTBEAT_URL"

Inside a shell script, put the ping at the very end so it can only be reached on a clean exit:

#!/usr/bin/env bash
set -euo pipefail

pg_dump mydb | gzip > /backups/mydb-$(date +%F).sql.gz
aws s3 cp /backups/mydb-$(date +%F).sql.gz s3://my-bucket/

curl -fsS -m 10 --retry 3 "$HEARTBEAT_URL"

In a Laravel scheduler, the framework has a hook for it:

$schedule->command('backup:run')
         ->dailyAt('03:00')
         ->thenPing(env('HEARTBEAT_URL'));

And in Python, a try/finally is the wrong instinct here — ping in the success path only:

import requests

def main():
    sync_inventory()
    requests.get(HEARTBEAT_URL, timeout=10)

The same one-liner works from a Kubernetes CronJob, a Windows Task Scheduler action, a GitHub Actions workflow, or a Docker container running supercronic. If it can make an HTTP request when it finishes, it can be monitored.

Getting the Grace Period Right

This is where most heartbeat setups go wrong in one of two directions.

Too tight and you get paged because a backup that normally takes six minutes took eleven. Alert fatigue sets in, and within a month everyone mutes the channel — which means the real failure goes unread too.

Too loose and a daily job with a 24-hour grace period can be broken for nearly two days before anyone hears about it.

A reasonable default is the job’s normal runtime plus a comfortable margin, not the interval between runs. A nightly job that finishes in ten minutes doesn’t need a six-hour window; forty-five minutes is plenty, and it means you find out at 3:45 AM instead of the following evening.

A laptop screen showing a dashboard chart

Send the Alert Where You’ll Actually See It

An alert that lands in an inbox you check twice a week is barely better than no alert. Route heartbeat failures to wherever your team already lives — Slack for work, Discord for community projects, email for the things you want a record of, or a webhook if you’re piping it into your own on-call setup.

UptimeDuty includes cron heartbeat monitoring on the free plan alongside HTTP and TCP checks, with email, Slack, Discord, and webhook alerts. Twenty monitors, no credit card, and it stays free — so putting a heartbeat on your backup job costs you about thirty seconds and nothing else.

A Short Checklist

  • Every scheduled job that would hurt if it silently stopped gets a heartbeat — backups first
  • Ping on success only, using && and not ;
  • Set the grace period from the job’s runtime, not its schedule
  • Route alerts to a channel someone actually reads
  • Check the dashboard after the first expected run, so you know the wiring works

The point isn’t to add monitoring to everything. It’s to make sure that the day your backup script breaks, you find out that day — not the day you need the backup.

Add a heartbeat monitor free → — takes about thirty seconds, no card needed.