Troubleshooting

Cron job not running? Check these seven things in order

Your crontab entry never fires or fails silently? Check the cron service, the syntax, PATH, % signs, permissions, file names and the logs, in that order.

Published ~12 min read

A cron job that “does nothing” either never started or started and failed without telling anyone. First prove which one it is: check that the cron service runs and look in its log for a CMD line with your command. Then fix the usual culprit, cron’s minimal environment, with absolute paths and a redirect of the output into a log file. Cron runs your line with /bin/sh, a PATH of /usr/bin:/bin and your home directory as the working directory, and it reads none of your shell’s startup files. That is why a command can work in your terminal and fail in cron.

first: did cron start the job?
# is cron running? (Rocky, AlmaLinux: crond instead of cron)
systemctl status cron

# did it start your job? (Rocky, AlmaLinux: journalctl -u crond)
journalctl -u cron --since "1 hour ago"

# Ubuntu and any other system running rsyslog
grep CRON /var/log/syslog | tail -20

# is the line installed, and for the user you think?
crontab -l
sudo crontab -l -u www-data

A CMD line means cron did its part and the command itself failed, so read steps 3 to 5 and 7. No CMD line at the expected minute means cron never started it: the service, the line, the file or the schedule is wrong (steps 1, 2 and 6). On Debian and Ubuntu the log looks like this:

journalctl -u cron
CRON[48211]: (root) CMD (/usr/local/bin/backup.sh)
CRON[48210]: (CRON) info (No MTA installed, discarding output)

The second line is the important one when something fails: the job printed an error, cron tried to mail it to you, found no mail server and threw it away. Step 7 shows how to keep that output.

1. Is cron installed and running?

Minimal installs, container images and some cloud images ship without a cron daemon. On Debian and Ubuntu the package and the service are called cron; on Rocky Linux, AlmaLinux and CentOS Stream the package is cronie and the service crond.

install and start cron
# Debian, Ubuntu
sudo apt update && sudo apt install -y cron
sudo systemctl enable --now cron

# Rocky Linux, AlmaLinux, CentOS Stream
sudo dnf install -y cronie
sudo systemctl enable --now crond

Inside a Docker container there is usually no cron and no systemd at all, so a crontab written there is never read. Schedule the job from the host instead, for example docker exec mycontainer /app/task.sh in the host’s crontab.

Where are the cron logs?

  • Debian 12 and newer: rsyslog is no longer installed by default, so there is no /var/log/syslog. Use journalctl -u cron.
  • Ubuntu: grep CRON /var/log/syslog, or journalctl -u cron.
  • Rocky Linux and AlmaLinux: journalctl -u crond, and /var/log/cron when rsyslog is installed.

Debian’s and Ubuntu’s cron logs only when a job starts. To log failed jobs with their exit status too, set EXTRA_OPTS='-L 5' in /etc/default/cron and restart the service with sudo systemctl restart cron.

2. Is the crontab line valid?

A cron line has five time fields, then the command. Where the line lives decides whether a user name sits in between:

crontab format
# .---------------- minute (0-59)
# |  .------------- hour (0-23)
# |  |  .---------- day of month (1-31)
# |  |  |  .------- month (1-12)
# |  |  |  |  .---- day of week (0-7, both 0 and 7 are Sunday)
# |  |  |  |  |
  */5 *  *  *  *        /usr/local/bin/check.sh     # crontab -e: no user field
  30  2  *  *  *  root  /usr/local/bin/backup.sh    # /etc/crontab, /etc/cron.d: user field

The mistakes that keep a line from ever running, or make it run at the wrong time:

  • A user field in the wrong place. In crontab -e the word root becomes the command (“root: not found”). In /etc/crontab and /etc/cron.d a missing user field makes cron read the first word of your command as a user name.
  • The wrong crontab. sudo crontab -e edits root’s crontab, not yours. crontab -l and sudo crontab -l show which one holds the line.
  • A star where you meant a number. * 2 * * * runs every minute from 02:00 to 02:59, sixty times. Once at 02:00 is 0 2 * * *.
  • Day of month and day of week together. When both fields are restricted, cron runs the job when either matches: 0 9 1 * 1 runs on the 1st of every month and on every Monday. For “the first Monday” use the days 1–7 and test the weekday in the command: 0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /usr/local/bin/report.sh.
  • A comment at the end of a line. Comments belong on their own line. After a variable, PATH=/usr/bin:/bin # comment makes the comment part of the value.

To check a schedule before it goes in, paste its five fields into the cron expression generator: it reads them back in plain English and lists the next run times, in UTC or in your own time zone.

Edit with crontab -e rather than touching the files under /var/spool/cron: it checks the syntax before installing (“errors in crontab file, can’t install”) and tells the daemon to reload. You never need to restart cron after crontab -e.

3. Does the command exist in cron’s PATH?

This is the “runs manually but not in cron” case. Cron’s PATH is /usr/bin:/bin, so everything in /usr/local/bin, /usr/sbin, /snap/bin, ~/.local/bin or a version manager is “not found”, and the job ends with exit code 127. Set the environment at the top of the crontab and use absolute paths:

crontab -e
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# cd first: cron starts in your home directory, so relative paths break
*/10 * * * * cd /home/deploy/app && /home/deploy/app/venv/bin/python sync.py >> /home/deploy/sync.log 2>&1
  • Write PATH out in full. Cron does not expand variables or ~ in these lines, so PATH=$HOME/bin:$PATH sets PATH to that literal text.
  • Python virtualenvs: call the interpreter inside the venv, as above, instead of activating it. source venv/bin/activate fails under /bin/sh with “sh: 1: source: not found”, because source is a bash word.
  • Node with nvm: use the full path that command -v node prints in your shell (it lives under ~/.nvm/versions/node/) and update the crontab when you switch Node versions.
  • Variables from .bashrc, such as API keys, do not exist in cron. Put them in a file the script reads, or in the crontab itself.

4. Are the % signs escaped?

In a crontab, an unescaped % ends the command: cron turns it into a newline and sends everything after it to the command’s standard input. Every date +%F in a cron line is therefore broken until you escape it as \%:

crontab -e
# broken: cron cuts the command at the first %
0 3 * * * tar czf /backup/site-$(date +%F).tar.gz /var/www/site

# fixed
0 3 * * * tar czf /backup/site-$(date +\%F).tar.gz /var/www/site

The same applies to find -printf, mysqldump file names and URL-encoded strings. The cleaner fix is to move the command into a script: inside a script file, % needs no escaping.

5. Can the script run at all?

Run exactly the path that is in the crontab, as the user the crontab belongs to. If that fails, cron is not the problem. The usual reasons:

check the script
ls -l /usr/local/bin/backup.sh      # needs an x: -rwxr-xr-x
head -1 /usr/local/bin/backup.sh    # needs a shebang such as #!/bin/bash
file /usr/local/bin/backup.sh       # "with CRLF line terminators" = Windows line endings

sudo chmod +x /usr/local/bin/backup.sh
sudo sed -i 's/\r$//' /usr/local/bin/backup.sh
  • No execute bit: “Permission denied”, exit code 126. See bash: Permission denied, including the case of a noexec mount.
  • No shebang: the script runs under /bin/sh, and bash syntax such as [[ or arrays fails with errors like “[[: not found”.
  • Windows line endings: a script saved on Windows fails with /bin/bash^M: bad interpreter. The bad interpreter guide covers every variant.
  • The wrong user: a job in your own crontab cannot write to /var/log or read root’s files. Test with sudo -u deploy /usr/local/bin/backup.sh.

6. Is the file in /etc/cron.d or cron.daily named and owned correctly?

Jobs dropped into a directory instead of a crontab have rules of their own, and breaking them fails silently. On Debian and Ubuntu:

  • No dots in the file name. run-parts, which runs /etc/cron.hourly, cron.daily, cron.weekly and cron.monthly, only accepts names made of letters, digits, hyphens and underscores. backup.sh is skipped without a word; backup runs. Files in /etc/cron.d follow the same naming rule.
  • Executable, for the cron.daily family. Scripts there need chmod +x. Files in /etc/cron.d are crontabs, not scripts, so they do not.
  • Owned by root and not group- or world-writable. Otherwise cron logs WRONG FILE OWNER or INSECURE MODE and ignores the file.
  • A user field and a final newline in /etc/cron.d files. A file whose last line has no newline is ignored as a whole (“Missing newline before EOF, this crontab file will be ignored”).
check the drop-in directories
run-parts --test /etc/cron.daily              # lists only what run-parts will run
sudo mv /etc/cron.daily/backup.sh /etc/cron.daily/backup

sudo chown root:root /etc/cron.d/backup
sudo chmod 644 /etc/cron.d/backup
tail -c1 /etc/cron.d/backup | od -c           # must show \n

Rocky Linux and AlmaLinux are more lenient about names, but the scripts in cron.daily still need the execute bit. To see when the daily scripts run, look at /etc/crontab on Debian and Ubuntu, or /etc/anacrontab where anacron handles them.

7. Where does the output go?

By default, cron mails a job’s output to the crontab’s owner. Most servers have no mail server, so the error message is discarded and all you see is that nothing happened. Send the output to a file instead:

crontab -e
MAILTO=""
15 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
  • Keep the order: >> file 2>&1 sends both output and errors to the file. 2>&1 >> file sends errors to the old output, which cron discards.
  • Pick a file the job may write to. A user’s job cannot create files in /var/log; use a path in its home directory.
  • Mail works too once a mail transfer agent is installed: set MAILTO=you@example.com, and cron sends output only when there is some. MAILTO="" turns mail off.
  • Log files grow. Add them to logrotate, or the disk fills up months later; checking disk usage shows where the space went.

Is cron using the timezone you think?

Cron schedules in the server’s local time, and many VPS images run on UTC, so a job set for 03:00 can run in the middle of your afternoon. Check the zone with timedatectl and change it like this:

set the server's timezone
timedatectl                                  # "Time zone: Etc/UTC (UTC, +0000)"
sudo timedatectl set-timezone Europe/Berlin
sudo systemctl restart cron                  # Rocky, AlmaLinux: crond

Restart cron afterwards: it reads the zone when it starts. On Rocky Linux and AlmaLinux, cronie also accepts CRON_TZ=Europe/Berlin at the top of a crontab to schedule just that crontab in another zone. Debian’s and Ubuntu’s cron handles one timezone only and has no CRON_TZ, so convert the times yourself there. If the zone has daylight saving time, avoid scheduling important jobs between 02:00 and 03:00.

Why does my @reboot job fail?

@reboot runs when the cron daemon starts during boot, which can be before the network is up, before a database accepts connections and before slow mounts are ready. Debian’s cron also skips @reboot jobs when the service is merely restarted later (Skipping @reboot jobs -- not system startup). A delay is the quick fix; a systemd service that waits for the network is the reliable one.

crontab -e
@reboot sleep 60 && /usr/local/bin/start-tunnel.sh >> /home/deploy/tunnel.log 2>&1

How do I test a job in cron’s environment?

env -i alone is not enough: dash, the /bin/sh of Debian and Ubuntu, sets a full default PATH when it starts without one, so the command still finds everything. Hand it cron’s values explicitly:

run a command the way cron does
env -i HOME="$HOME" LOGNAME="$(id -un)" SHELL=/bin/sh PATH=/usr/bin:/bin \
  /bin/sh -c 'cd && /usr/local/bin/backup.sh'; echo "exit code: $?"

To see the real thing, add * * * * * env > /tmp/cron-env.txt 2>&1 to the crontab, wait a minute, compare the file with env in your shell, and remove the line again. While testing a schedule, follow the log live with journalctl -fu cron.

How do I stop a slow job from overlapping itself?

Cron starts a job on schedule whether or not the previous run has finished. A backup that takes twelve minutes, scheduled every ten, piles up until the server runs out of memory. Wrap it in flock, which skips the run while the last one still holds the lock. Put the lock file somewhere the crontab’s user may write; /run/lock is root-only on Rocky and AlmaLinux:

crontab -e
*/10 * * * * flock -n /home/deploy/backup.lock /usr/local/bin/backup.sh >> /home/deploy/backup.log 2>&1

Should I use a systemd timer instead?

For anything important, a systemd timer avoids most of the traps above. Its output lands in the journal under the unit’s name, the environment is whatever you define in the service, it can wait for the network, and with Persistent=true it catches up on a run that was missed while the server was off. Cron never catches up.

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

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
/etc/systemd/system/backup.timer
[Unit]
Description=Run the nightly backup

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target
enable and check
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
journalctl -u backup.service -n 20

How service files work, including environment variables and restart rules, is covered in creating a systemd service.

Frequently asked

Where are the cron logs on Ubuntu, Debian and Rocky Linux?

On Ubuntu, run 'grep CRON /var/log/syslog' or 'journalctl -u cron'. Debian 12 and newer install no rsyslog by default, so there is no /var/log/syslog and 'journalctl -u cron' is the place. On Rocky Linux and AlmaLinux use 'journalctl -u crond', or /var/log/cron when rsyslog is installed. These logs show that a job started, not what it printed, so redirect the job's output to a file.

Why does my script run manually but not in cron?

Cron gives jobs a minimal environment: /bin/sh as the shell, a PATH of /usr/bin:/bin, the home directory as the working directory and none of your shell startup files. Commands in /usr/local/bin, a virtualenv or nvm are then 'not found', and relative paths point to the wrong place. Use absolute paths, cd into the project first, set a full PATH= line at the top of the crontab and append '>> /path/job.log 2>&1' to see the actual error.

How do I run a cron job every 5 minutes?

Use */5 in the minute field: '*/5 * * * * /path/to/command'. It runs at :00, :05, :10 and so on. To limit it to working hours on weekdays, write '*/5 9-17 * * 1-5 /path/to/command'. Writing '5 * * * *' instead runs the job once an hour, at five past.

Does cron catch up on jobs missed during a reboot?

No. Cron only runs jobs whose time comes while the daemon is running; a run missed while the server was down or rebooting is skipped. anacron catches up daily, weekly and monthly jobs (it already runs cron.daily on Rocky Linux and AlmaLinux, and can be installed on Debian and Ubuntu), and a systemd timer with Persistent=true catches up its missed run at the next boot.

Related articles

All troubleshooting