Your crontab entry looks right, the command works in your terminal, and nothing happens. Here are the 12 reasons cron jobs fail to run, how to check each one, and how to find out next time before anyone else does.
The command works when you paste it into your terminal. The crontab line looks correct. And yet the backup file never appears, the report never lands in anyone’s inbox, and there is nothing in any log you can find.
This is the first post in our Cron in production series. It starts with the most common question of all: why is my cron job not running? Below are the 12 causes we see most often, roughly in the order you should check them, with the command that confirms or rules out each one.
These are two different problems, and people lose hours by mixing them up. Before anything else, find out whether cron even tried to start your job.
On Debian and Ubuntu, cron logs every command it starts to syslog:
grep CRON /var/log/syslog
# or, on systems with journald only:
journalctl -u cron --since "1 hour ago"On RHEL, Rocky, Alma and Fedora the daemon is called crond and logs to its own file:
grep CROND /var/log/cron
journalctl -u crond --since "1 hour ago"If you see a CMD (...) line with your command at the expected time, cron did its part and your job failed on its own. Skip to causes 4 to 9 and cause 12. If there is no such line, cron never started it, and causes 1 to 3 and 10 to 11 are the likely ones.
Sounds too obvious to check, which is exactly why it gets skipped. Minimal server images, fresh VPS templates and almost every Docker image ship without a running cron daemon.
systemctl status cron # Debian, Ubuntu
systemctl status crond # RHEL family
pgrep -a cron # anywhere, including containersIf it is not running, start it and make sure it comes back after a reboot with systemctl enable --now cron. Inside a container, nothing starts cron for you: it has to be the main process or run under a process supervisor. We will cover that properly in a later post in this series.
Five fields, in this order: minute, hour, day of month, month, day of week. The classic mistakes:
* 2 * * * runs every minute between 02:00 and 02:59, not once at 2 AM. You wanted 0 2 * * *.0 0 1 * 1 runs on the 1st of the month and on every Monday, not “on the 1st if it is a Monday”. When both day fields are restricted, cron treats them as OR.*/45 * * * * does not run every 45 minutes. It runs at minute 0 and minute 45 of every hour, so the gaps are 45 and 15 minutes.Paste your expression into our cron expression parser and check the next run times it lists. If they are not what you expected, the problem is solved before you touch anything else.
A single machine has several places where cron jobs live, and they do not all use the same format:
crontab -e edits your user’s crontab. sudo crontab -e edits root’s, which is a different file./etc/crontab and files in /etc/cron.d/ are system crontabs. They have an extra user field between the schedule and the command.# user crontab (crontab -e): no user field
0 2 * * * /opt/scripts/backup.sh
# /etc/cron.d/backup: user field required
0 2 * * * root /opt/scripts/backup.shCopy a line from one format to the other without adjusting it and it breaks: in /etc/cron.d your script path is read as the user name, and in a user crontab cron tries to run a command called root. List what is actually installed with crontab -l and sudo crontab -l, and read through /etc/cron.d/.
This is the most common cause of “it works in my terminal”. Cron starts jobs with a minimal environment, usually PATH=/usr/bin:/bin. Anything installed in /usr/local/bin, /snap/bin, ~/.local/bin or a version manager directory is simply not found.
The fix is either absolute paths everywhere, or a PATH line at the top of the crontab:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * node /opt/app/scripts/cleanup.jsUse command -v node in your normal shell to find the full path of a binary.
Cron does not read .bashrc, .profile or .bash_profile. Every variable you exported there (database URLs, API keys, NODE_ENV, JAVA_HOME) does not exist when the job runs.
You can define variables directly in the crontab, the same way as PATH above. Or keep them in a file your script loads itself:
0 2 * * * . /opt/app/.env.cron && /opt/app/scripts/report.shThe quickest way to see exactly what cron gives your job is to ask it. Add a temporary entry, wait a minute, then remove it:
* * * * * env > /tmp/cron-env.txtCompare /tmp/cron-env.txt with the output of env in your terminal and the missing piece is usually obvious.
Cron runs commands with /bin/sh unless told otherwise. On Debian and Ubuntu that is dash, which does not understand bash features like [[ ]], arrays, source or {1..10}. The job fails with a syntax error that nobody sees.
Either set the shell at the top of the crontab:
SHELL=/bin/bashor, better, put the logic into a script that starts with #!/usr/bin/env bash and call the script from cron.
In a crontab line, % is special: cron converts it into a newline and feeds everything after the first one to the command as standard input. This breaks the very common pattern of date-stamped filenames:
# broken: cron sees the command end at "+"
0 2 * * * pg_dump mydb > /backups/db-$(date +%F).sql
# works
0 2 * * * pg_dump mydb > /backups/db-$(date +\%F).sqlEscape every % with a backslash, or move the command into a script where the rule does not apply.
If cron calls a script directly, the script needs to be executable and start with a shebang line:
chmod +x /opt/scripts/backup.sh
head -1 /opt/scripts/backup.sh # should print something like #!/usr/bin/env bashTwo quieter variants of the same problem. A script edited on Windows may have \r\n line endings, and the kernel then looks for an interpreter literally named bash\r. Check with file backup.sh (it will say “with CRLF line terminators”) and fix with sed -i 's/\r$//' backup.sh. And the job may run as a different user than you tested with, one that cannot read the config file or write to the output directory. Our chmod calculator helps when you need to work out the right mode.
Two rules that trip up almost everyone who deploys cron files with configuration management or a Dockerfile:
/etc/cron.d/ are skipped if their name contains a dot, on Debian and Ubuntu at least. backup.cron and app.conf are ignored, backup and app-jobs are read. The files must also be owned by root and not writable by group or others.tail -c1 /etc/cron.d/backup | xxd # should end with 0a
ls -l /etc/cron.d/If /etc/cron.allow exists, only users listed in it can have a crontab. If it does not exist but /etc/cron.deny does, everyone except the users listed there can. Hardened images sometimes ship with restrictive versions of these files. crontab -e will tell you when you are denied, but a crontab installed by other means may just sit there unused.
Cron runs jobs at the time they are due, and only then. If the server was rebooting at 02:00, or the laptop was asleep, that run is gone for good; cron does not catch up on missed runs. For machines that are not always on, anacron or systemd timers with Persistent=true are the right tool.
Time zones cause the other half of “it never ran when I expected”. Cron uses the system time zone, and most servers are set to UTC. A job scheduled for 0 9 * * * on a UTC server runs at 09:00 UTC, which is 11:00 in Warsaw in summer and 04:00 in New York. Check with timedatectl. Some cron implementations, cronie among them, support a CRON_TZ variable for per-crontab time zones; check man 5 crontab on your system before you rely on it. Daylight saving time transitions add their own surprises, which get a dedicated post later in this series.
By default cron emails a job’s output to the crontab owner. On most modern servers there is no mail transfer agent installed, so that output, including every error message, is quietly discarded. The job fails every night and there is no trace of why.
At minimum, send output somewhere you can read it:
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1Most of the causes above come down to “cron’s environment is not your terminal’s environment”. You can reproduce cron’s conditions directly, without waiting for the next scheduled run:
env -i SHELL=/bin/sh PATH=/usr/bin:/bin HOME="$HOME" LOGNAME="$USER" \
/bin/sh -c '/opt/scripts/backup.sh'If the script fails here, it will fail under cron, and you get to see the error message right away.
Every cause in this post has the same thing in common. Nothing tells you. The job does not throw an error at you, it just stops happening, and you find out days later when someone needs the backup, the invoice run, or the report.
Logging the output (cause 12) helps you diagnose a failure after you know about it. It does not help you notice that the job never started, because a job that does not run does not write anything to its log. For that you need something that expects to hear from the job, and raises an alarm when it does not.
That is what heartbeat monitoring does, and it is what we built HelloCron for. The job sends a ping when it starts and when it finishes, and HelloCron alerts you if a ping arrives with an error, or if the expected ping never arrives at all:
HELLOCRON_API_KEY=your_api_key
0 2 * * * curl -fsS -H "Authorization: Bearer $HELLOCRON_API_KEY" "https://api.hellocron.com/ping/nightly-backup?status=run"; /opt/scripts/backup.sh && curl -fsS -H "Authorization: Bearer $HELLOCRON_API_KEY" "https://api.hellocron.com/ping/nightly-backup?status=complete" || curl -fsS -H "Authorization: Bearer $HELLOCRON_API_KEY" "https://api.hellocron.com/ping/nightly-backup?status=fail"Or, with the HelloCron CLI, which captures the exit code and the error output for you (note the absolute path, see cause 4):
0 2 * * * /usr/local/bin/hellocron run nightly-backup /opt/scripts/backup.shThe walkthrough in Monitor a cron job in 60 seconds takes you through the full setup. The free plan covers 15 monitors, which is enough for most servers.
PATH line?.bashrc?SHELL=/bin/bash or a proper shebang?% escaped?/etc/cron.d filenames?cron.allow and cron.deny?Next in the series: Where are cron logs? covers where the cron daemon logs on Ubuntu, Debian, RHEL, Alpine and inside containers, and how to make your jobs’ output easy to find.
Sign up, get an API key, paste the curl into your cron. That's it.