Skip to content
AlertPing

Guides

How to monitor a cron job

| Guides | 9 min read

To monitor a cron job, you invert the direction of the check: instead of a monitoring service polling the job, the job pings the monitoring service every time it finishes successfully. This is called heartbeat monitoring, or a dead man's switch. You give the monitor an expected period and a grace period; if the ping does not arrive in that window, you get paged. It is one line of curl at the end of the job, and it is the only technique that catches a cron job which never ran at all.

Why cron jobs fail silently

A web server is easy to monitor because it answers. You send it a request, it responds or it does not, and you have your answer. A cron job answers nothing. It runs at 3am on a box nobody is watching, and when it stops running it produces exactly the same amount of noise as when it succeeds: none. That is why the backup story is the same at every company. The dump job started failing in March, nobody found out until the restore in July, and those four months of history looked identical to four clean ones.

The three approaches people reach for first all share the same defect:

  • MAILTO in the crontab. Cron emails you the job's output. In practice the mail is swallowed by a misconfigured local MTA, lands in a folder nobody opens, or is so noisy from chatty jobs that everyone filters it. Worse, MAILTO only reports output from a job that ran. If the cron daemon is dead, the box is off, or someone's crontab -r wiped the file, there is no output, so there is no mail. Silence looks identical to success.
  • Checking exit codes in a wrapper script. Better, because you are reacting to failure rather than to text. But the wrapper only helps if the wrapper runs. Kill the machine, the container, the cron daemon or the scheduler, and your careful error handling never executes. Silence again.
  • Logging to a file. A log is a record, not an alarm. It works only if a human reads it, on a schedule, forever. Nobody does this.

The line worth remembering: with cron, the absence of a failure signal is not evidence of success. Only a positive success signal proves the job ran. Every approach above tries to detect failure. Heartbeat monitoring detects the missing proof of success instead, which is a strictly larger set: it includes every way the job can die without saying a word.

How heartbeat monitoring works

The mechanics take about two minutes, in any language, on any host:

  1. Create a heartbeat monitor and tell it the expected period (every 24 hours, every hour, a cron expression) and a grace period (how late is still acceptable).
  2. The monitor hands you a unique ping URL.
  3. Your job requests that URL as its last action, and only if everything before it succeeded.
  4. If a ping does not arrive within the period plus the grace, the monitor fires an alert.

The ping is an outbound HTTPS request, so the job can sit behind a firewall, on a private subnet, inside a VPC, and still be monitored without exposing anything. And because the monitoring service lives somewhere else entirely, it keeps watching when your infrastructure does not. On a server you provision and deploy to yourself, the cron daemon is one more service that can quietly stop, and a monitor running on the same box would stop with it. That is the dead man's switch principle: the alarm fires because the system stopped reporting, not because it managed to report a problem.

The one-line version: a crontab that pings on success

crontab -e ▸ nightly backup, 3am

0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 https://hb.alertping.com/p/YOUR-MONITOR-KEY > /dev/null

That is a complete cron monitoring setup. The curl flags are not decoration; each one exists to stop a specific bad behavior:

  • -f makes curl treat an HTTP error (say a 500 from the monitoring service) as a failed command instead of quietly succeeding with an error page as the body.
  • -s silences the progress meter, which is what otherwise generates a pointless cron email every single night.
  • -S puts real error messages back on stderr, so if the ping genuinely cannot be delivered you still find out. Use -s and -S together, always.
  • -m 10 caps the whole request at 10 seconds so a hung network connection cannot leave a cron process running until the next invocation collides with it.
  • --retry 3 retries on transient network errors. A dropped packet on your side should not page you at 3am.
  • > /dev/null (or -o /dev/null) throws away the response body. Same reason as -s: no output, no cron mail.

The mistake everyone makes: && versus ;

The && in that line is the entire point of it. In a POSIX shell, a && b runs b only if a exited 0. So the ping happens only when the backup actually worked.

Write ; instead of && and the shell runs the ping unconditionally: the backup script can explode, exit 1, corrupt its own output, and the heartbeat still lands on time. Now you have monitoring that reports green while the job burns, which is meaningfully worse than no monitoring, because it buys you confidence you have not earned. Same trap with a pipeline: backup.sh | tee log.txt returns tee's exit status, not the backup's, unless you set pipefail. Check the character. It is the single most common cron monitoring bug, and it is silent by construction.

A wrapper that signals start, success and failure

The one-liner catches a job that did not run. It does not catch a job that started and then hung, which is what a database dump does when it blocks on a lock, and it does not tell you how long the job took. Send three signals instead of one: a start ping, a success ping, and a failure ping carrying the output.

/usr/local/bin/run-backup.sh

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

PING="https://hb.alertping.com/p/YOUR-MONITOR-KEY"

# 1. Signal the start. This starts the duration clock and lets the
#    monitor alert on a job that begins but never finishes.
curl -fsS -m 10 --retry 3 -o /dev/null "$PING/start"

# 2. Do the real work. Capture output so a failure can carry it.
OUTPUT=$(/usr/local/bin/backup.sh 2>&1)
CODE=$?

# 3. Report the outcome, and only the truthful one.
if [ "$CODE" -eq 0 ]; then
  curl -fsS -m 10 --retry 3 -o /dev/null "$PING"
else
  printf '%s' "$OUTPUT" \
    | curl -fsS -m 10 --retry 3 -o /dev/null --data-binary @- "$PING/fail"
fi

exit "$CODE"

Two details matter. set -e is deliberately absent: with it, the script would exit the moment backup.sh failed and never send the failure ping, which is precisely the ping you care about. And the final exit "$CODE" preserves the real exit status for anything upstream (systemd, a CI runner, Kubernetes) that also inspects it. The monitor now knows three things it could not know before: that the job started, that it finished, and how long it took.

Laravel: the scheduler already has hooks for this

If your jobs run through the Laravel scheduler rather than raw crontab, you do not need a wrapper. The scheduler exposes ping hooks on every task, in routes/console.php on Laravel 11 and up, or app/Console/Kernel.php on older versions:

routes/console.php

$schedule->command('backup:run')
    ->dailyAt('03:00')
    ->pingOnSuccess('https://hb.alertping.com/p/YOUR-MONITOR-KEY')
    ->pingOnFailure('https://hb.alertping.com/p/YOUR-MONITOR-KEY/fail');

// Long job? Bracket it, and you get duration for free:
$schedule->command('reports:build')
    ->hourly()
    ->pingBefore('https://hb.alertping.com/p/OTHER-KEY/start')
    ->pingOnSuccess('https://hb.alertping.com/p/OTHER-KEY');

pingOnSuccess() fires only on a zero exit code: the && semantics from earlier, enforced by the framework. Its sibling thenPing() fires regardless of outcome, so treat it the way you treat ;: almost never what you want from a health signal. These hooks need Guzzle (composer require guzzlehttp/guzzle). The scheduler cannot monitor itself, though, because every hook depends on the single schedule:run cron entry firing. Put a heartbeat on a task that runs everyFiveMinutes() and you are also monitoring the scheduler, and therefore cron, and therefore the box.

Kubernetes CronJobs: identical principle

A Kubernetes CronJob gives you retries and pod logs, and still no alarm when the controller is wedged, the node pool is drained, or the image pull fails on a schedule nobody is watching. Same fix, at the end of the container command:

k8s ▸ cronjob.yaml

spec:
  schedule: "0 3 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: backup
              image: registry.example.com/backup:1.4
              command: ["/bin/sh", "-c"]
              args:
                - /usr/local/bin/backup.sh &&
                  curl -fsS -m 10 --retry 3 -o /dev/null
                  https://hb.alertping.com/p/YOUR-MONITOR-KEY

alertping

Give every scheduled job a heartbeat

Create a monitor, paste one curl line, pick a grace period. The first night the backup skips, your phone knows. SMS, Slack, email and webhooks are included on every plan.

Choosing the period and the grace period

This is where teams either get quiet, trustworthy alerts or train themselves to ignore the pager. The period is when you expect the ping. The grace period is how much lateness is normal before it becomes an incident. Set the grace from the worst run you have actually seen, not the average one. A nightly backup that usually takes 12 minutes will, on the night someone bulk-imported a million rows, take 40. A 15-minute grace pages you at 3:15am for a healthy job that finished at 3:40. Do that twice and the team mutes the channel.

JobPeriodGraceWhy
Nightly backup (12 min typical)1 day30 to 60 minAbsorbs the heavy night without hiding a real miss. You still find out before breakfast.
Hourly sync1 hour10 to 15 minRoughly one quarter of the period: long enough for jitter and a retry, short enough that you never miss two runs before hearing about it.
Every-5-minute queue worker5 min5 to 10 minDo not alert on one skipped tick. Grace of one to two periods means a genuine stall pages you within about 10 minutes.
Weekly report (Mondays)7 days2 to 6 hoursNobody reads it before the standup, so a few hours of grace costs nothing and removes every false page.

Rule of thumb: grace should exceed your worst observed run time, and for short-period jobs it should be at least one full period. If you find yourself shrinking the grace to catch failures faster, the real problem is usually a job whose run time is drifting, which brings us to the part most teams skip.

Monitor the work, not just the exit code

A heartbeat proves the process ran and exited 0. It does not prove the process did anything useful. This is the failure mode that survives naive cron monitoring, and it is the one that hurts:

  • The zero-row success. The sync job's API credentials expired. It fetched an empty list, wrote zero rows, exited 0, and pinged cleanly for three weeks while your dashboards happily rendered stale numbers. Green board, dead pipeline.
  • The empty backup. pg_dump exited 0 but the disk filled, so the file is 4KB. The heartbeat cannot tell.
  • The creeping duration. A job that took 4 minutes in January takes 55 now. It has not failed yet. It will, the first time it overruns its window and two copies run concurrently on the same table.

The fix is to make the job assert on its own output before it pings. Have the script fail itself when the result is nonsense, and let && do the rest:

assert ▸ a backup smaller than 10MB is not a backup

pg_dump mydb | gzip > /backups/db.sql.gz
SIZE=$(stat -c %s /backups/db.sql.gz)

if [ "$SIZE" -lt 10000000 ]; then
  echo "dump is only $SIZE bytes, refusing to report success" >&2
  exit 1
fi

Now the heartbeat means something specific: not "a process exited 0" but "a backup of plausible size exists". Duration is the other half. Because the start ping and the success ping bracket the run, the monitor knows how long every execution took, and a job whose median run time doubles over a month is a warning you get weeks before the outage. Pair the heartbeats with a server monitoring tool watching disk and memory on the same box, and the cause is usually sitting right there in the graph.

How do I know if a cron job ran?

Have the job tell you. Add a curl call to a unique heartbeat URL at the end of the crontab line, after &&, so it fires only on exit code 0. If the ping arrives, it ran and succeeded. If it does not arrive within the expected window, you are alerted. Checking /var/log/syslog or grep CRON tells you cron tried to start it, which is not the same thing.

How do I monitor a cron job?

Use heartbeat monitoring. Create a monitor with an expected period and a grace period, get its unique ping URL, and append && curl -fsS -m 10 --retry 3 https://hb.alertping.com/p/YOUR-KEY to the job so it pings only on success. When a ping is missed, the service alerts you by SMS, Slack, email or webhook. No agent, and it works behind a firewall.

What is a dead man's switch in monitoring?

A dead man's switch is a monitor that alerts when a signal stops arriving, rather than when an error is reported. Borrowed from train controls, where releasing the lever stops the train. Applied to cron: your job checks in on every successful run, and silence is what triggers the alarm. It catches failures that can never announce themselves, including a dead server.

Why do cron jobs fail silently?

Because cron has no concept of an expected outcome. It fires a command and forgets it. Failures land in mail nobody reads or logs nobody opens, and a job that never runs (dead daemon, wiped crontab, powered-off box, PATH change) produces no output at all. Silence and success look identical from outside, so nothing surfaces until someone needs the result.

Where this fits

Uptime checks tell you the front door is open. Heartbeats tell you the work behind it is still happening. Most teams build the first and skip the second, then discover the gap on restore day. AlertPing's cron job monitoring is the heartbeat side: a unique ping URL per job, a schedule and grace period you set, start and failure pings if you want duration, and the same downtime alerts and escalation chains as every other check, from $19 a month on every plan. It sits next to the HTTP, SSL and port checks described in how to monitor website uptime, because a backup that stopped running six weeks ago is an outage too. You just have not had it yet.

Know the second your site goes down

Checks every 30 seconds, confirmed from 3 regions, alerts on every channel. Running in under a minute.

See pricing