Cron job monitoring: how to know when a cron job didn't run

Cron is reliable at starting things and silent about everything else. If the server was down, the crontab got overwritten, the job hung, or it exited 0 having done nothing, you usually find out when someone asks where the report is. Here's how to check a job right now, and how to set up heartbeat monitoring so you're told within minutes next time.

Updated November 3, 2026 · Linux cron (cronie, Debian/Ubuntu cron) and systemd timers · 9-minute read

Short answer: make every cron job report in. Wrap the command so it pings a monitor outside the server when it finishes cleanly, and calls a /fail URL with its output when it doesn't. Add flock so runs can't overlap and timeout so they can't hang. If the success ping doesn't arrive on schedule plus a grace period, you get an alert, whatever the cause.

Why cron jobs fail without anyone noticing

Cron's job is to start a command at a time. It doesn't know what the command was meant to achieve, and by default it reports problems by local email, which most servers can't deliver. The common failures fall into four groups:

What happenedTypical causesDoes cron tell you?
It never startedThe server was down or rebooting at run time, the cron daemon stopped, the crontab was replaced by a deploy or a crontab -r, a file in /etc/cron.d has a name cron ignores (for example one containing a dot) or is missing its trailing newline, or a daylight-saving change skipped the hour.No. There's no run, so there's no error.
It started and failedcommand not found because cron's PATH is minimal, a relative path that only works from your home directory, a permissions problem, an expired credential, a full disk.Only via MAILTO, and only if local mail works.
It hung or piled upA network call with no timeout, a lock held by a dead process, a run that takes longer than the interval so the next one starts on top of it.No. The process is still "running".
It exited 0 with nothing doneThe query returned zero rows, an upstream file wasn't there yet, a script swallowed an error with || true, the backup was written but empty.No. As far as cron knows, it worked.

Watching the cron log only covers the first column. Watching for a success signal covers all four, because they all look the same from the outside: the "done" message never arrives.

Check whether a cron job ran, right now

  1. Look for the result. Is the backup file there with today's date and a sensible size? Did the rows land? If the output is missing, you already have your answer.
  2. Check that cron started it. The cron log records each command it launches. No entry at the expected time means the problem is the schedule, the daemon or the machine.
  3. Check the entry itself. Make sure it's in the crontab you think it is (yours, root's, or /etc/cron.d), and that the time fields mean what you expect in the server's time zone.
  4. Run it the way cron does. A near-empty environment and /bin/sh reproduce most "works by hand, fails in cron" problems in seconds.
# Did cron start the job? (Debian/Ubuntu)
grep CRON /var/log/syslog | tail -n 20
journalctl -u cron --since "today"        # Debian/Ubuntu with systemd
journalctl -u crond --since "today"       # RHEL, Fedora, Rocky, Alma

# Is the cron daemon running, and is the entry really there for this user?
systemctl status cron        # or: systemctl status crond
crontab -l                   # the current user's crontab
sudo crontab -l -u deploy    # another user's crontab
ls /etc/cron.d/

# Run the job the way cron does: almost no environment, /bin/sh
env -i HOME="$HOME" PATH=/usr/bin:/bin SHELL=/bin/sh /bin/sh -c '/opt/jobs/backup.sh'
echo "exit code: $?"

That explains this failure. The next section makes sure you don't have to go looking for the next one.

Set up heartbeat monitoring for cron

A heartbeat monitor waits for each run to check in. Instead of trying to detect every possible failure, it notices the one thing they have in common: silence. The examples use a Pulsecheck ping URL like https://pulsecheck.run/p/YOUR-JOB-ID, but the cron side works with any heartbeat service.

1. Find out how often the job should run

Read the schedule from the crontab: 30 2 * * * is once a day, */15 * * * * is every 15 minutes. Then pick a grace period: the longest normal run plus a margin. A backup that takes up to 20 minutes might get 45 minutes of grace. Too tight and you'll get false alarms; too loose and you'll hear about problems late.

2. Create a monitor and copy its ping URL

The monitor must live somewhere other than the server it watches. A watchdog script on the same box can't tell you the box is down. In Pulsecheck, add a job with the period and grace from step 1 and copy its unique ping URL.

3. Wrap the job in a heartbeat script

Rather than editing every script, run each job through one small wrapper. It bounds the run with timeout, pings on success, and on failure POSTs the exit code's context (the last lines of output) to /fail so the alert tells you what broke. The ping is the last thing that happens, and a failed ping never changes the job's own exit code.

#!/usr/bin/env bash
# /opt/jobs/run-with-heartbeat.sh: run any command and report the result to Pulsecheck
# Usage: run-with-heartbeat.sh <ping-url> <command> [args...]
set -uo pipefail
PING="$1"; shift
LOG=$(mktemp)

timeout 30m "$@" >"$LOG" 2>&1
code=$?

if [ "$code" -eq 0 ]; then
  curl -fsS -m 10 --retry 3 "$PING" >/dev/null || true
else
  # 124 means timeout killed it. Send the exit code and the last lines of output.
  tail -c 1500 "$LOG" | curl -fsS -m 10 --retry 3 -X POST "$PING/fail" \
    -H "Content-Type: text/plain" --data-binary @- >/dev/null || true
fi
rm -f "$LOG"
exit "$code"

Make it executable with chmod +x /opt/jobs/run-with-heartbeat.sh. An exit code of 124 means timeout stopped a run that went over time.

4. Prevent overlap with flock

flock -n /tmp/backup.lock ... takes a lock before running the command and exits straight away if another run still holds it. A slow run then causes one skipped run (which the heartbeat notices) rather than a pile of processes fighting over the same files.

5. Fix the cron environment

Set SHELL and PATH at the top of the crontab, use absolute paths for scripts and files, and escape any % in a crontab line as \% (an unescaped one is treated as a newline, which commonly breaks date +%F). Set MAILTO="" if your server has no working mail, so output isn't quietly queued for a mailbox nobody reads.

6. Send a count for jobs that can succeed with nothing

Some jobs can exit 0 having done nothing: an import that found no rows, a report built from an empty query. For those, POST a small JSON summary such as {"count": 42} (or add ?count=42 to a GET) and add an output rule like count at least 1. A run that breaks the rule is flagged as Ran but output looks wrong. (Output checks are on Pulsecheck's Pro and Agency plans. Missed-run and /fail alerts work on every plan, including the free one.)

7. Test the alert

Run the cron line once by hand and watch the monitor turn green. Then break it on purpose: point the job at a path that doesn't exist and confirm the /fail alert arrives with the error text, and comment the line out for one cycle to confirm the missed-run alert. Don't trust an alert you've never seen fire.

Copy-paste examples

Replace YOUR-JOB-ID with the ID from your own ping URL. Give every job its own monitor.

A crontab using the wrapper

# m  h  dom mon dow  command
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""

# Nightly backup at 02:30, no overlapping runs, bounded and reported
30 2 * * * flock -n /tmp/backup.lock /opt/jobs/run-with-heartbeat.sh https://pulsecheck.run/p/YOUR-JOB-ID /opt/jobs/backup.sh

# Hourly import that must actually import something: send a count
5 * * * * flock -n /tmp/import.lock /opt/jobs/import.sh

A job that reports a count

The ERR trap reports any failing command to /fail with the line number. The success ping carries the row count, so an output rule can catch an hour where nothing was imported.

#!/usr/bin/env bash
# /opt/jobs/import.sh: report how many rows were imported, not just that it finished
set -euo pipefail
PING="https://pulsecheck.run/p/YOUR-JOB-ID"
trap 'curl -fsS -m 10 "$PING/fail" -X POST -d "import.sh failed on line $LINENO" >/dev/null || true' ERR

rows=$(/opt/jobs/import-orders --since "1 hour ago" --print-count)

curl -fsS -m 10 --retry 3 -X POST "$PING" \
  -H "Content-Type: application/json" -d "{\"count\": $rows}" >/dev/null || true

systemd timer instead of cron

On systemd machines a timer is often a better fit: Persistent=true runs a job that was missed while the machine was off as soon as it's back, and the logs land in journalctl -u backup.service. The same wrapper reports the result.

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/opt/jobs/run-with-heartbeat.sh https://pulsecheck.run/p/YOUR-JOB-ID /opt/jobs/backup.sh

# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true

[Install]
WantedBy=timers.target

# enable it:  sudo systemctl enable --now backup.timer

Running jobs on a schedule somewhere else? The same pattern works for scheduled AI agents and GitHub Actions and for n8n workflows.

Common mistakes

FAQ

How do I know if a cron job ran?

Check the cron log (grep CRON /var/log/syslog on Debian and Ubuntu, or journalctl -u cron / -u crond on systemd machines) to see whether cron started it, then check the job's own output or artifact. To know automatically, have the job ping a heartbeat monitor when it finishes; if the ping doesn't arrive on schedule, you get alerted.

Why does my cron job work by hand but not from cron?

Almost always the environment. Cron runs commands with /bin/sh and a minimal PATH (often just /usr/bin:/bin), without your shell profile, so commands like node, python from a virtualenv, or anything in /usr/local/bin may not be found. Use absolute paths or set PATH at the top of the crontab, and remember that an unescaped % in a crontab line is treated as a newline.

Does cron send an alert when a job fails?

Only partly. Cron can email a job's output through MAILTO if the machine has working local mail, but most servers don't, and it says nothing when the job never ran because the machine was down or the entry was deleted. A heartbeat monitor outside the server covers those cases.

What is cron heartbeat monitoring?

Each run sends an HTTP request (a ping) to a monitoring service when it finishes. The service knows how often to expect it. If a ping is late beyond a grace period, or the job reports a failure, it alerts you. It's also called dead man's switch monitoring, because silence is what triggers the alert.

Should the ping go at the start or the end of the job?

At the end, after the work succeeded. A ping at the start only proves cron fired. A ping at the end proves the job finished, and a /fail ping from the error path tells you straight away when it didn't.

How do I stop overlapping cron runs?

Wrap the command in flock -n with a lock file, for example flock -n /tmp/backup.lock /opt/jobs/backup.sh. If the previous run still holds the lock, the new one exits immediately instead of piling up. Pair it with timeout so a stuck run can't hold the lock forever.

Know when a cron job goes quiet.

Pulsecheck gives every cron job its own ping URL and alerts you by email (or Slack on Pro and Agency) when a run is missed, reports a failure, or comes back with nothing. Free for 3 jobs.

Start free

Related: How to know if your AI agent stopped running · How to monitor an n8n workflow that fails silently · all Pulsecheck guides. Cron behavior differs a little between implementations (cronie, Debian's cron, BusyBox); check man 5 crontab on your system.