Uptime checksHTTP, keyword and TLS checks on your addresses, with the evidence of every failure kept.
Cron heartbeatsOne curl line at the end of a job. Silence past the grace window is the alert.
Status pagesA page your customers read, on your own domain, with the incident in plain words.
ExplanationsA written reason with the alert: what failed, what the checks saw, what to look at first.
on every plan Alerts to six channelsImportTeams
APIRead, pause, acknowledge and post updates from your own scripts.
MCP serverAsk your AI assistant what is down, then acknowledge or post an update.
WebhooksSigned JSON when an incident opens, is acknowledged or recovers.
Heartbeat guidescrontab, GitHub Actions, Railway, Node, Go, WordPress and Kubernetes.
DocsShort guides, each one task, with the lines to copy.
GuidesWhy monitors send false alarms, why cron jobs stop, and the fix.
ChangelogThe big things we shipped, newest first.
Trust and securityWhere your data lives, who processes it, how long we keep it.
Referral program soonRecommend Upglory to another team. In the works.
Contact
Guides menu Cron job not running

Cron job not running? How to find out when it silently stops

Cron does not tell anyone when a job fails or never starts. Here is how to find out why it stopped, and how to hear the next time it does.

7 minute read

The problem

Cron is quiet by design. It starts a command at the time you gave it, and if the command fails, or never starts, cron tells nobody. At best it mails the output to a local mailbox on the server that nobody reads.

So a backup job stops on a Tuesday because the disk filled up or a password changed, and the first sign comes weeks later, on the day you need the backup and the newest one is a month old. Invoice runs, report emails and certificate renewals fail the same way.

There are two jobs here. First, find out why the job is not running now. Then make sure you hear the next time it stops, without having to remember to look.

First, find out why

Is cron running?

On Debian and Ubuntu, systemctl status cron. On Fedora and the other Red Hat family systems the service is called crond. In a container, cron is often not running at all, because the container starts only its main process.

Did cron start the job?

Cron logs every command it starts. Try grep CRON /var/log/syslog on Debian and Ubuntu, or journalctl -u cron --since today (-u crond on Red Hat systems). A line with your command means cron started it and the job failed afterwards. No line means cron never ran it.

Is the line right?

Five fields, then the command. This one runs at 02:30 every day:

crontab
# minute  hour  day-of-month  month  day-of-week  command
  30      2     *             *      *            /usr/local/bin/backup-nightly

The usual mistakes: * 2 * * * runs every minute from 02:00 to 02:59, not once. When both day of month and day of week are set, cron runs on either, so 0 2 1 * 1 runs on the first of the month and on every Monday. A % in the command means a new line to cron and has to be written \%, which breaks the familiar date +%F in backup file names. And the last line of a crontab needs a line break after it, or some versions of cron ignore it.

Does it work in cron's environment?

This is the most common cause. Your shell has a long PATH and your profile. Cron gives the command a minimal environment, often a PATH of only /usr/bin:/bin and /bin/sh as the shell. A command that works when you type it fails under cron because node, php or pg_dump lives somewhere else. Use full paths, or set PATH=/usr/local/bin:/usr/bin:/bin at the top of the crontab. To see what cron sees, add this line for a minute and compare the file with env in your own shell:

crontab
* * * * *  env > /tmp/cron-env.txt

Relative paths

Cron starts the command in the user's home directory, not where the script lives. A script that reads ./config.yml or writes to backups/ works from its own folder and fails from cron. Change to the right directory at the top of the script, or use absolute paths throughout.

The user and permissions

A crontab you edit with crontab -e runs as you. The system crontab and the files in /etc/cron.d/ have an extra field for the user, between the schedule and the command, and a line without it fails. The script has to be executable (chmod +x), and the user has to be allowed to read what it reads and write where it writes. Scripts in /etc/cron.daily/ are started by run-parts, which on Debian and Ubuntu skips any file name with a dot in it, so a backup.sh there never runs.

The timezone

Cron uses the server's timezone, and most servers run on UTC. A job meant for two in the morning your time can run hours earlier or later. Check with timedatectl. On a server that follows local time, the change to and from summer time can skip or repeat a job scheduled between 01:00 and 03:00.

The machine was off

Cron does not catch up. If the server was rebooting, asleep or scaled down at 02:30, that night's run simply does not happen. anacron, or a systemd timer with Persistent=true, runs a missed job once the machine is back.

The output goes nowhere

Cron mails a job's output to its user through the server's local mail, which is often not set up, so the error that explains everything is thrown away. Set [email protected] at the top of the crontab if the server can send mail, or send the output to a log you can read:

crontab
30 2 * * *  /usr/local/bin/backup-nightly >> /var/log/backup-nightly.log 2>&1

The 2>&1 matters. Errors go to standard error, and without it they are still lost.

Then watch it

Fixing today's failure says nothing about the next one, and a log only helps if someone reads it. Turn the silence into a signal instead: the job reports when it finishes, and something outside the server expects that report on a schedule and tells you when it does not arrive. This is the heartbeat pattern. The report is one HTTP request to an address that belongs to the job:

crontab
30 2 * * *  /usr/local/bin/backup-nightly && curl -fsS -m 10 --retry 3 https://ping.example.com/nightly-backup > /dev/null

&& or ;

With &&, curl runs only when the job exits with 0, so a failed backup sends nothing and the silence is noticed. With ;, curl runs whatever happened, and the monitor hears that all is well from a job that failed. Make sure the job exits with something other than 0 when it fails: set -e at the top of a shell script does most of that.

The curl flags

-f makes curl fail on an HTTP error instead of treating an error page as success. -sS keeps it quiet but still prints an error. -m 10 stops it waiting more than ten seconds, so a slow network cannot hold the job open. --retry 3 tries again when the network drops a request, so a lost ping does not become a false alarm of its own.

Report a failure as a failure

Silence only tells you once the grace window has passed. The job usually knows sooner. If your monitor has a separate address for failures, call it, and keep the job's own exit code so cron and your logs still see the failure. A small wrapper does both:

shell
#!/bin/sh
# run-and-report: run the job, then say how it went.
PING=https://ping.example.com/nightly-backup

/usr/local/bin/backup-nightly
code=$?
if [ "$code" -eq 0 ]; then
  curl -fsS -m 10 --retry 3 "$PING" > /dev/null
else
  curl -fsS -m 10 --retry 3 "$PING/fail" > /dev/null
fi
exit "$code"

Grace time

A heartbeat needs two numbers: how often the ping is expected, and how late it may be before anyone is told. The period is the schedule, not the run time, so a daily job is expected every 24 hours. Grace covers the variation, such as the run that takes forty minutes instead of fifteen because the database grew. So a daily job needs a window longer than a day: 24 hours plus grace, and grace is the longest run you have seen plus a margin. Too short, and a slow night wakes you. Too long, and you hear late.

Worked examples

A nightly database backup

The script stops at the first error, and pipefail makes a failed dump fail the whole pipe. Without it, a dump that failed piped into a gzip that worked exits with 0, and you keep an empty file every night. The % is safe here because it is in the script, not in the crontab line.

bash script
#!/bin/bash
set -euo pipefail
cd /var/backups/db
pg_dump shop | gzip > "shop-$(date +%F).sql.gz"
find . -name 'shop-*.sql.gz' -mtime +14 -delete
crontab
PATH=/usr/local/bin:/usr/bin:/bin
30 2 * * *  /usr/local/bin/backup-nightly >> /var/log/backup-nightly.log 2>&1 && curl -fsS -m 10 --retry 3 https://ping.example.com/nightly-backup > /dev/null

The ping is expected every 24 hours. If the backup takes up to twenty minutes, an hour of grace is plenty.

A queue worker that should ping while healthy

A worker that runs all the time has no end to ping from. Ping on a schedule while it is healthy instead, and expect a ping every five minutes with ten minutes of grace:

crontab
*/5 * * * *  curl -fsS -m 5 http://127.0.0.1:9000/health > /dev/null && curl -fsS -m 10 --retry 3 https://ping.example.com/queue-worker > /dev/null

The health endpoint should check something real, such as that the worker finished a job recently. If the worker crashes, hangs or the machine goes away, the pings stop. Better still, ping from inside the worker's loop after each batch.

One monitor per job

Give every job its own address. If three jobs share one, a ping from any of them hides the silence of the other two. Name each after the job and the server, such as nightly-backup on db-1.

How Upglory does it

An Upglory heartbeat is the address a job pings and the promise it keeps: a period, and 30 minutes of grace unless you change it. Its address looks like https://ping.upglory.io/abc123. Join the ping with && as above. Ping /start when the job begins and the finish ping records how long it ran, so a long run is not mistaken for a missed one. Ping /fail to report a failure at once instead of waiting for the grace window.

Nothing alerts until the first ping arrives, so a job you have not deployed yet raises no alarm. When a ping is late past the grace window, the alert says which job and when its last ping came, and when the next run pings, the heartbeat recovers on its own.

The crontab guide in the docs has the lines to copy, and the heartbeats page shows the rest.

Start free 20 heartbeats on Free, with no card