Skip to content
Kaspar

How to Monitor Cron Jobs with Heartbeats (and Catch Silent Failures)

Cron jobs fail quietly. Learn how heartbeat monitoring works and how to protect backups, imports and scheduled tasks with ping URLs, grace periods, exit codes and runtime limits.

Kaspar Team 9 min read

When a website goes down, someone usually notices. When a cron job stops running, nobody does. There is no error page and no red status light, and in most setups not even an email. The nightly backup is simply missing. The product feed still shows last week's prices. The invoices never go out. You find out when someone needs the data and it isn't there.

This article explains why ordinary monitoring misses failed cron jobs, how heartbeat monitoring solves the problem, and how to cover your most important jobs in a few minutes.

Why cron jobs fail silently

Cron does one thing well: it starts commands at fixed times. It does not care whether those commands succeed. That simplicity is exactly what makes failures easy to miss. The usual suspects:

  • The job never starts. A typo in the crontab, a server that came back after a reboot without the cron service, a container where cron was never running, or a deployment that replaced the crontab.
  • The environment differs from your shell. Cron runs with a minimal PATH and without your shell profile. A command that works in your terminal suddenly can't find php, node or python.
  • The job exits with an error. The database is unreachable, an API key has expired, the disk is full.
  • The job hangs. A network call without a timeout, or a lock that is never released, and the process sits there for hours doing nothing.
  • The error report goes nowhere. Cron emails output to the local user if MAILTO is set and the machine can actually send mail. On many servers that message ends up nowhere, or in a mailbox nobody reads.

In every one of these cases, the outside world looks fine. Your site responds and the server answers pings. An uptime check that requests a URL has no way of knowing that last night's job never ran.

The idea: let the job check in

Classic uptime monitoring works by polling: a service regularly asks your server "are you there?" That does not work for cron jobs, because there is nothing to ask. A job only exists while it runs.

Heartbeat monitoring flips the direction. Instead of the monitor calling the job, the job calls the monitor. Each job gets its own secret ping URL and requests it when it finishes successfully. Because the monitoring service knows the schedule, it knows when the next ping is due. If the ping doesn't arrive, you get an alert.

The elegant part is that it doesn't matter why the ping is missing. Deleted crontab, powered-off server, a script that crashes on line 40: the result is the same. No signal means an alert. You don't have to anticipate every possible failure.

Three signals: start, success and failure

A single ping at the end of a job already catches most problems. Using three signals gives you a much clearer picture:

  1. Start: the job reports that it has begun. This lets the service measure how long each run takes, and a hung job stands out because a start is never followed by an end.
  2. Success: the job completed cleanly.
  3. Failure: the job knows something went wrong and says so right away, so you don't have to wait for the grace period to run out.

In Kaspar, every cron monitor has a ping URL like https://kaspar.app/p/<token>, with optional suffixes:

Request Meaning
/p/<token> Run finished successfully
/p/<token>/start Run started
/p/<token>/fail Run failed
/p/<token>/<exitcode> Run finished; the exit code decides (0 is success, anything else is a failure)

The endpoints accept GET, POST and HEAD. With POST you can send part of the job's output in the request body, so the context is right there when something breaks.

Schedule, grace period and maximum runtime

For the service to know when a ping is due, you give it the schedule: either a fixed interval ("every 15 minutes") or a cron expression exactly as it appears in your crontab, such as 30 2 * * * for 2:30 every night.

Two more settings decide whether you get useful alerts or noise:

Grace period. No job finishes at exactly the same second every time. A backup might take five minutes one day and twenty the next. The grace period is how long the service waits after the expected time before a missing ping counts as a failure. A good rule of thumb is a little more than the longest normal runtime you have seen.

Maximum runtime. If your job sends a start signal, the service can check that it also finishes in time. A job that suddenly runs much longer than usual is often an early warning: growing data volumes, a slow third-party API, or a process that is stuck.

Adding heartbeats to your crontab

The simplest pattern chains the ping to your command with &&, so it only fires when the command exits with code 0:

30 2 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 https://kaspar.app/p/YOUR_TOKEN > /dev/null

Each curl flag has a purpose:

  • -f makes curl exit with an error on HTTP errors instead of quietly printing the error page.
  • -sS hides the progress meter but still shows real errors.
  • -m 10 caps the request at ten seconds, so the ping can never hold up your job.
  • --retry 3 retries on transient network problems.

Report the exit code, not just success

With && you only learn about a failure once the grace period has passed. You'll hear sooner if you always report the exit code:

30 2 * * * /usr/local/bin/backup.sh; curl -fsS -m 10 --retry 3 https://kaspar.app/p/YOUR_TOKEN/$? > /dev/null

The semicolon makes curl run regardless of the outcome, and $? holds the exit code of the previous command. Exit code 0 counts as success; anything else triggers an alert immediately. One crontab gotcha: a literal % has to be escaped as \%. $? is fine as it is.

Start and finish in a wrapper script

For jobs where you care about runtime, a small wrapper script is worth it:

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

PING="https://kaspar.app/p/YOUR_TOKEN"

curl -fsS -m 10 --retry 3 "$PING/start" > /dev/null

output=$(/usr/local/bin/backup.sh 2>&1)
status=$?

curl -fsS -m 10 --retry 3 --data-raw "$(printf '%s' "$output" | tail -c 10000)" "$PING/$status" > /dev/null

exit $status

It reports the start, runs the job, sends the tail of the output along with the exit code, and passes the exit code back to cron, so cron's own behaviour doesn't change.

Laravel, systemd and containers

Plenty of applications don't put scheduled tasks straight into a crontab. The principle stays the same:

  • Laravel's scheduler has built-in pingBefore(), thenPing(), pingOnSuccess() and pingOnFailure() methods to call a URL around a scheduled task, so each task can have its own monitor. Monitor the scheduler itself as well: if schedule:run stops being called, none of your tasks run.
  • systemd timers: add the ping with ExecStartPost= or call it at the end of the script the service runs.
  • Kubernetes CronJobs and other containers: put the ping in the job's entrypoint. An external heartbeat is especially useful here, because failed pods, and their logs, tend to be cleaned up quickly.

Which jobs to monitor first

You don't need to cover everything on day one. Start with the jobs whose failure would go unnoticed the longest and hurt the most:

  1. Backups. Otherwise you discover a broken backup at the worst possible moment.
  2. Imports and syncs. Product data, stock levels, exchange rates, CRM syncs.
  3. Billing and email. Invoice runs, payment reminders, newsletters.
  4. Housekeeping. Log rotation, clearing old sessions or temp files. When these stop, disks eventually fill up.
  5. Certificate renewal. A renewal job that fails silently only shows up when the certificate expires.

If you're an agency looking after many client projects, use a consistent naming scheme such as client-project-job. You'll see at a glance who is affected when an alert comes in.

Common setup mistakes

  • Pinging at the start. A heartbeat sent when the job begins tells you nothing about whether it succeeded. Use the start signal for that and put the success ping at the end.
  • No timeout on the ping. Without -m, a hanging network request can stall your job.
  • A grace period that's too tight. Frequent false alarms train people to ignore alerts. Tune the grace period to real runtimes.
  • One monitor for several jobs. Each job needs its own ping URL; otherwise one job's success hides another's failure.
  • Leaking the ping URL. Treat the token like a password, because anyone who has it can report success. Keep it in environment variables, not in public repositories.

Alerts that reach the right people

An alert only helps if it reaches someone who can act on it. Route cron alerts to wherever your team already works. Kaspar notifies you by email, Slack or Telegram when a ping is missing, a job reports a failure or a run exceeds its maximum runtime, and sends an all-clear once the job is back to normal.

Wrapping up

Cron jobs are quiet when they work, and unfortunately just as quiet when they don't. Heartbeat monitoring turns that silence into a signal: if the ping doesn't arrive, you know, whatever the cause. One ping URL per job, a sensible grace period and the exit code as the result are enough to protect your most important jobs in a few minutes.

In Kaspar, you create a cron monitor, enter the interval or cron expression, and paste the ping URL into your crontab. Uptime checks for your websites and broken link scans run in the same account. See our pricing page for the plans.

  • Cron monitoring
  • Heartbeats
  • DevOps
  • Backups

Related articles

Broken links SEO

How to Find Broken Links and Protect Your SEO

Where broken links come from, what they really mean for visitors and search engines, and a practical process to find, prioritise and fix them with the right redirects.

· 8 min read