How to know your cron job actually ran

Cron tells you nothing when a job does not run. Here is how to find out what happened today, and how to get an alert the next time a job goes quiet.

CronEngine team

· 8 min read

A backup that stopped three weeks ago. An invoice run that skipped a month. A sync that has been failing every night since a server move. Cron jobs rarely fail loudly. They just stop, and you find out when someone needs the result. This guide goes from the checks you can run right now to a setup that warns you within minutes.

In short

  1. 1Check the cron log and the job's own output to see what happened.
  2. 2Make the job ping a URL, but only when it succeeds: job && curl ….
  3. 3Get an alert when the ping does not arrive in time. That catches every kind of failure, including a job that never started.

Why cron fails silently

Cron starts commands on time and that is all it does. It does not check the result, and the only way it reports anything is by emailing the output to the user, which needs a mail setup that most servers do not have. So a job can go wrong in several ways without anyone noticing:

  • It never started. The crontab was replaced during a deploy, the server was down at that minute, or the cron service itself stopped.
  • It started and failed at once. Cron runs with a minimal environment: a different PATH, no variables from your shell profile, and the home directory as working directory. A script that works in your terminal can fail on the first line under cron.
  • It failed halfway. The disk was full, a credential expired, an API answered with an error.
  • It hung. It is still waiting on a lock or a network call, and the next run piles up behind it.
  • It ran and did nothing. It finished without errors but exported zero rows.

None of these produce an error you will see. The first one cannot produce an error at all, because nothing ran.

Check whether it ran, right now

Cron logs every command it starts. Where that log is depends on the distribution:

Terminal Shell
# Debian and Ubuntu
grep CRON /var/log/syslog
journalctl -u cron --since today

# Red Hat, AlmaLinux, Rocky Linux
grep backup /var/log/cron
journalctl -u crond --since today

# Is the job still in the crontab at all?
crontab -l

A line like CRON[1234]: (deploy) CMD (/usr/local/bin/backup.sh) means cron started the command at that time. It does not mean the command worked. For that you need its output, so keep it in a log file instead of throwing it away:

crontab -e Cron
# Append everything the job prints, errors included, with a timestamp per run
0 3 * * * { date; /usr/local/bin/backup.sh; } >> /var/log/backup.log 2>&1

If a job works by hand but not under cron, run it the way cron does, with an almost empty environment. Most problems show up immediately:

Terminal Shell
env -i HOME="$HOME" PATH=/usr/bin:/bin /bin/sh -c '/usr/local/bin/backup.sh'

One crontab trap catches almost everyone once: a % in a crontab line means a new line, so date +%F breaks the command. Write date +\%F instead, or move the command into a script.

Make failures visible

A job can only report a failure if it notices one. In a shell script, these lines at the top make it stop at the first error and exit with a non-zero status, instead of carrying on and finishing as if all went well:

backup.sh Shell
#!/bin/bash
# Stop on the first failing command, on unset variables,
# and when any part of a pipeline fails.
set -euo pipefail

With a working mail setup, a MAILTO=you@example.com line at the top of the crontab emails you whatever a job prints. That helps for errors, but it stays quiet when a job never starts, and it gets noisy for jobs that print on success. Which leads to the one check that covers every case.

The reliable way: a heartbeat

Turn it around. Instead of waiting for the job to report a problem, let it report success, and treat silence as the problem. Each time the job finishes well, it sends a request to a unique URL. A monitoring service knows when that request is due, and alerts you when it does not arrive in time. This is also called a dead man's switch.

In a crontab it is one extra command. The && means the ping is only sent when the job succeeded:

crontab -e Cron
0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 -o /dev/null https://cronengine.com/ping/hb_YOUR_TOKEN

A failed backup, a backup that never started, a server that was off: in every case the ping goes missing and you hear about it. For more detail, ping at the start and on failure too. Then you also see how long each run took, notice a run that started but never finished, and hear about a failure straight away instead of after the grace period:

backup-with-ping.sh Shell
#!/bin/sh
PING=https://cronengine.com/ping/hb_YOUR_TOKEN

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

if /usr/local/bin/backup.sh; then
  curl -fsS -m 10 --retry 3 -o /dev/null "$PING"
else
  curl -fsS -m 10 --retry 3 -o /dev/null "$PING/fail"
fi

-m 10 keeps a slow network from hanging the job, --retry 3 survives a short blip, and -o /dev/null keeps cron from mailing you the reply.

Set up a heartbeat

In CronEngine it takes a minute:

  1. Create a heartbeat and give it the name of the job.
  2. Tell it when to expect a ping: the same cron expression as the job, in the same timezone as the server, or simply "every 24 hours".
  3. Set a grace period that is longer than the job normally takes, so a slow night does not wake you.
  4. Copy the ping URL into the crontab line or script above, and run the job once to see the first ping arrive.

From then on you get an email, Slack, Discord, Telegram or webhook alert when a ping is missing or a run reports /fail, and another one when it recovers. The free plan includes 2 heartbeats, and pings never count against anything.

Beyond crontab

The same idea works wherever scheduled work runs:

  • Laravel scheduler. Add ->pingOnSuccess($url) and ->pingOnFailure($url . '/fail') to the scheduled command. Laravel recipe.
  • GitHub Actions. Ping in a last step with if: success(), and ping /fail with if: failure(). GitHub Actions recipe.
  • Kubernetes CronJobs and Docker. End the container command with && curl …, the same as in a crontab.
  • Your own code. Any language that can send an HTTP request can ping. Examples in Python, Node.js and PowerShell.

What each check catches

ProblemCron logOutput and MAILTOHeartbeat
Never started No No Yes
Failed with an error No Yes Yes
Hung and never finished No No Yes
Server was down No No Yes
Got slower over time No No Yes

The last row needs the start ping, which is what lets a heartbeat measure each run. A job that ran and did nothing is the one case no generic check can catch: only the job knows that zero rows is wrong. Make the script exit with an error when its result looks empty, and the heartbeat takes it from there.

Questions

How do I check if a cron job ran?

On Debian and Ubuntu, search the system log with grep CRON /var/log/syslog, or use journalctl -u cron. On Red Hat, AlmaLinux and Rocky Linux the log is /var/log/cron. These show that cron started the command, not that it succeeded.

Why did my cron job not run?

The usual causes are a different environment than your shell (PATH, working directory, missing variables), an unescaped % in the crontab line, the wrong timezone, a server that was down or rebooting, or a crontab that was changed or replaced. Running the exact command with env -i in front of it reproduces most environment problems.

What is a dead man's switch?

A check that raises the alarm when an expected signal stops arriving, rather than when an error is reported. For cron, the job pings a URL each time it finishes, and you are alerted when a ping is missing. It is the only kind of check that catches a job that never started.

Should the ping come before or after the job?

After, and only when the job succeeded, so a failure means no ping. Add an optional ping at the start as well to measure how long each run takes and to notice a job that started but never finished.

What if the ping itself fails?

Use a short timeout and a few retries, as in the examples here, so a network blip does not hang the job or cause a false alarm. The grace period gives a late ping extra time to arrive.

Do heartbeat pings count as runs?

No. In CronEngine pings are free and never use the run allowance of your cron jobs. Only the alerts share the daily notification limit.

Spotted a mistake, or something that behaves differently for you? Tell us and we will update the guide.

Share X LinkedIn Reddit