Why Your Cron Job Doesn't Run
Five reasons a cron job never runs: a minimal PATH, an unescaped percent sign, the wrong crontab, output lost to mail, and a script that assumes your shell.
Why your cron job does not run
A cron job that "never runs" has almost always run. It ran in an environment that is not your shell, it failed in the first second, and the message went somewhere you are not reading. Five causes explain nearly every report: the search path, the percent sign, the wrong crontab file, output that went to mail, and a script that expects a login session.
cron is a daemon (a background service) that reads crontab files and starts commands on a schedule. It does not read your .bashrc, it does not open a terminal, it does not start a login shell, and it does not tell you when a command fails. Every cause below follows from those four facts.
Work through them in order, and start with the question underneath all of them: did cron fire at all? "cron never started the job" and "the job started and died" are different problems with nothing in common, so answer that one first.
Did cron fire at all?
The daemon has a different unit name on different distribution families. Check both, then read the log.
systemctl status cron
systemctl status crond
journalctl -u cron --since "2 hours ago"
journalctl -u crond --since "2 hours ago"Debian and Ubuntu call the unit cron. Fedora, Rocky and Alma call it crond. Only one of those names exists on a given machine, so one of the two commands reporting an unknown unit is normal and not a fault.
Read the entries your own system wrote. Do not go looking for a line copied out of a guide, because the wording differs between cron implementations and between logging setups. You are checking two things only: is there an entry at the minute your schedule names, and does that entry name your command. An entry naming your command means cron did its part and the failure is inside the command. No entry at all means cron never had your schedule, which is cause 3 below.
Some images send cron messages through rsyslog into a file instead of the journal. Look in /var/log for a file named after cron or syslog, then read the end of it.
ls -l /var/log
sudo tail -n 50 /var/log/syslogIf neither the unit nor the log exists, cron may simply not be installed. Minimal cloud images and containers often leave it out.
dpkg -l cron
rpm -q cronie
sudo apt install cron
sudo dnf install cronie
sudo systemctl enable --now cronCause 1: cron does not have your PATH
Your interactive shell builds PATH from /etc/profile, ~/.profile, ~/.bashrc and everything those files source. None of that runs for a cron job. cron starts the command with its own short environment, so a program that lives outside the standard system directories is not found. Anything under /usr/local/bin, /opt, a language version manager, a Python virtual environment or a Go workspace is a candidate. The job fails on its first line, and the shell writes a "not found" style error whose exact wording depends on which shell ran it.
Find the real path of every command your job uses.
command -v docker
command -v node
readlink -f "$(command -v node)"Then either write those absolute paths into the job, or set PATH once at the top of the crontab.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * /usr/local/bin/mytool runTake that list from your own machine with echo "$PATH" and drop anything that exists only inside an interactive session. One rule matters here: cron does not expand variables in these assignment lines. PATH=$PATH:/usr/local/bin stores the literal text $PATH:/usr/local/bin, so the job ends up with a search path holding no usable directory at all. Write the whole list out.
A version manager needs more than a path. nvm, pyenv, rbenv and asdf install a shell function or a shims directory from your .bashrc, and a cron job never reads that file. Call the versioned binary by absolute path, or source the manager's init script as the first line of your own script.
Cause 2: the percent sign ends your command
In the command field of a crontab, % is not an ordinary character. The first unescaped % ends the command. Everything after it is handed to the command as standard input, and each further % becomes a newline. That is a real cron feature for feeding short input to a program, and it is also why a date-stamped filename is the classic broken crontab entry.
Write 0 3 * * * /usr/bin/tar -czf /srv/backups/site-$(date +%F).tar.gz /srv/site and tar never sees a formatted date. cron cuts the line at the first %, so the shell receives an unfinished command substitution and the rest of your line arrives as standard input. Escape every percent with a backslash.
0 3 * * * /usr/bin/tar -czf /srv/backups/site-$(date +\%F).tar.gz /srv/siteTwo layers read that one line, in order. \% is a cron rule, applied by cron before it starts anything. $(date +\%F) is command substitution, applied later by the shell cron starts. Knowing which layer owns which character is the whole trick.
The safer habit is to keep logic out of the crontab entirely. Put it in a script, where the percent sign has no special meaning.
#!/bin/bash
set -euo pipefail
stamp="$(date +%F)"
tar -czf "/srv/backups/site-${stamp}.tar.gz" /srv/siteThe crontab line then holds a path and a redirect and nothing else. A crontab you can read at a glance is a crontab you can debug.
Cause 3: which crontab did you edit?
There is no single crontab. There are several files, with different owners and different field counts, and a job written into the wrong one is invisible.
crontab -eedits the crontab of the user who runs the command.sudo crontab -eedits root's. Two people debugging the same box often end up reading two different files.sudo crontab -l -u deploylists another user's crontab, which is how you confirm what is actually installed for the account that should run the job./etc/crontaband every file in/etc/cron.dcarry one extra field between the schedule and the command: the user to run as. Paste a five-field user crontab line into/etc/cron.dand the first word of your command is read as a username.- Files in
/etc/cron.dmust be named with letters, digits, underscores and hyphens. A file calledbackup.shorsite.confis skipped because of its name alone. Rename it tobackupand check your log again. - Files in
/etc/cron.dshould be owned by root, and must not be writable by group or others.ls -l /etc/cron.dshows you both facts at once. - Scripts dropped into
/etc/cron.dailyand its siblings follow the same naming rule and must also carry the execute bit. A missing execute bit is a silent skip. /etc/cron.allowand/etc/cron.denydecide who may install a crontab at all. If either exists on your box, read it before assuming your user is allowed one.
Install a user crontab with the crontab command instead of editing the spool file by hand, because crontab parses the file before installing it. When you save, read what the command prints back to you. If it refuses the file, the previous version stays live and your change never took effect, which looks exactly like cron ignoring you.
The owner also decides permissions. A job in root's crontab creates root-owned files that the application reading them may not be able to write. A job in a normal user's crontab cannot read a root-only directory. Match the owner to the work: application maintenance belongs to the application's own account, which is the reasoning behind replacing WordPress wp-cron with a system cron job. The mode of the files your job creates comes from the umask it inherits, and that is another value which is not your shell's, so how umask sets file permissions is worth reading if a job's output lands unreadable.
Cause 4: the output went to mail nobody reads
cron collects everything a job writes to standard output and standard error. If the job wrote anything at all, cron hands that text to the local mail system, addressed to the crontab's owner or to whatever MAILTO names. On a stripped-down VPS there is usually no MTA (mail transfer agent) installed, so nothing delivers it. Your error existed for a moment and then went nowhere. That is the whole reason a broken job looks silent.
Send the output to a file you control instead.
0 3 * * * /usr/local/sbin/backup-site.sh >> /var/log/backup-site.log 2>&1>> appends standard output to the file. 2>&1 points standard error at whatever standard output points at right now, so it has to come after the redirect. Written the other way round, as 2>&1 >> file, standard error keeps its original destination, and the error you are hunting is exactly the part that never reaches the file.
The journal is the other good target. logger writes into syslog under a tag you choose.
0 3 * * * /usr/local/sbin/backup-site.sh 2>&1 | logger -t backup-siteRead it back with journalctl -t backup-site. This keeps the job's own output next to the cron entries, so the timeline is easy to follow. If you also need a record of which person ran which command on the box, that is a separate system, and auditing user commands on your server covers it.
MAILTO="" at the top of a crontab turns mail off for the jobs below it. Setting MAILTO to a real address only helps if a working MTA exists, so prove that mail leaves the box before you depend on it.
One rule while debugging: never append > /dev/null 2>&1. It is the most popular line in every crontab, and it throws away the only evidence you have. Put it back later if you want, once the job works.
Cause 5: the script assumes an environment cron does not give it
Once the command is found and its output is captured, what remains is everything else your session hands you for free.
- The shell may not be bash. Check with
ls -l /bin/sh. On Debian and Ubuntu it points at dash, so the double bracket test, arrays andsourcefail with a syntax error. Give the script a#!/bin/bashline and call the script, or setSHELLat the top of the crontab. - The working directory is not the one you were standing in. Use absolute paths everywhere, or
cdto the directory on the first line of the script. A relative path is the most common single reason a job "works when I run it by hand". - The locale is not your session's. Anything that formats a date or a number, or sorts text, can produce different output under a different
LANG. If a later step parses that output, set the locale in the script instead of hoping. - There is no TTY (terminal). A command that asks for confirmation, opens an editor or draws a progress bar can hang or exit. Add whatever non-interactive flag the tool offers.
- There is no SSH agent.
SSH_AUTH_SOCKis not in cron's environment, so ansshorrsynccommand that worked because your agent was loaded now fails to authenticate. Give the job its own key, owned by the job's user. - There is no user session bus, so
systemctl --userfrom a cron job fails untilXDG_RUNTIME_DIRis set. A system unit is the better answer.
On Fedora, Rocky and Alma there is one more suspect. SELinux confines cron jobs, so a job touching a path with an unexpected label is denied even when the file permissions look correct. Check for denials with sudo ausearch -m avc -ts recent, and read SELinux basics for a server before you switch anything off.
The one minute probe that shows you cron's environment
Stop guessing what cron's environment holds and read it. Write a script that dumps everything, schedule it every minute, wait, then read the file.
cat > /home/deploy/cron-probe.sh <<'EOF'
#!/bin/bash
echo "=== probe ==="
date -Is
pwd
id
echo "SHELL=$SHELL"
echo "LANG=$LANG"
command -v node || echo "node is not on this PATH"
env | sort
EOF
chmod +x /home/deploy/cron-probe.shAdd one line to the crontab of the user the real job runs as, with absolute paths on both sides.
* * * * * /home/deploy/cron-probe.sh >> /home/deploy/cron-probe.log 2>&1Wait a minute, then read /home/deploy/cron-probe.log and compare it against the same commands run in your own shell. The PATH line, the working directory and the locale usually explain the failure on their own. Note two details of the setup: the percent signs live inside the script, where cron's rule does not apply, and the log path is one the job's user can write to.
Delete that crontab line the moment you have your answer. A job that runs every minute and appends to a file will fill a small disk, and it will do it quietly.
Is the schedule the one you meant?
A user crontab line starts with five fields: minute, hour, day of month, month, day of week. Two of them interact in a way that surprises people.
When day of month and day of week are both restricted, meaning neither one is *, cron runs the job when either field matches. 0 0 13 * 5 is not "Friday the 13th". It runs at midnight on the 13th of every month, and at midnight every Friday. To get one specific day, leave one of the two fields as * and test the other one inside the script.
cron uses the system timezone. Many VPS images ship set to UTC (coordinated universal time), so a job you scheduled for 03:00 runs at 03:00 UTC, which may be the middle of your afternoon. timedatectl prints what your box actually uses. Read yours rather than assuming it matches your laptop.
Two more schedule traps are worth knowing. @reboot fires when cron itself starts, which is not the same moment as the network being ready, so a job needing DNS or a remote host can fail at boot and succeed on every manual run afterwards. And nothing stops a slow job from starting again while the previous copy is still running. Wrap it in a lock.
*/5 * * * * /usr/bin/flock -n /tmp/backup-site.lock /usr/local/sbin/backup-site.sh >> /var/log/backup-site.log 2>&1flock -n gives up immediately when the lock is already held, so the overlapping run stops instead of piling up on top of the first one.
When a systemd timer is the better tool
cron is good at one thing: run this command at this time. It is weak everywhere else. A timer gives you the journal without any redirect, an exit status you can query later, ordering against network-online.target, and a randomized delay so a hundred servers do not all start at the same second. When your job needs any of that, a systemd service and timer on a VPS is less work than defending a crontab line. Retry behaviour belongs there too, because systemd restart policies decide what happens after a failure, and cron has no answer to that question at all.
Keep cron for the small jobs. Move anything with dependencies or a retry policy to a timer. Both can run on the same server, so this is not a migration you have to finish in one sitting.
FAQ
Why does my cron job work by hand but fail from cron?
Because your shell and cron's environment are different. Your login shell reads /etc/profile and ~/.bashrc, which set PATH, the locale and your agent variables. cron starts the command with none of that, from a different working directory, sometimes with a different shell. Use absolute paths for every command, set what you need at the top of the crontab or inside the script, and schedule a one minute probe job that runs env | sort, pwd and id into a log file so you can read cron's real environment instead of guessing at it.
How do I check whether cron actually ran my job?
Read the daemon's log. Use journalctl -u cron on Debian and Ubuntu, or journalctl -u crond on Fedora, Rocky and Alma; some images route the messages through rsyslog into a file under /var/log instead. Look for an entry at the minute your schedule names and check that it names your command. No entry means cron never had the schedule, so confirm you edited the right crontab. An entry with no result means the command started and died, so capture its output with a redirect.
Why does date +%Y break inside a crontab?
cron treats % as special in the command field. The first unescaped % ends the command, everything after it is passed to that command as standard input, and each further % becomes a newline. So a date-formatted filename never reaches the program you wrote it for. Escape each percent as \%, or move the command into a script and call the script from cron, since inside a script the percent sign has no special meaning.
Where does my cron job's output go?
To the local mail system, addressed to the crontab's owner or to whatever MAILTO names. Most VPS images have no mail transfer agent installed, so the message is discarded and the job looks silent. Redirect the output to a file with >> /path/to/log 2>&1, keeping that order so standard error follows standard output, or pipe it through logger -t myjob and read it back with journalctl -t myjob. Do not use > /dev/null 2>&1 while you are still debugging.
Should I use cron or a systemd timer?
Use cron for a simple command at a fixed time, especially one you may need to move to a machine that does not run systemd. Use a timer when you want the output in the journal without redirecting, a queryable exit status, ordering after the network is up, a randomized start delay, or a retry policy after failure. Both can run on the same server, so you can move jobs one at a time as they earn it.