When a cron job prints anything, cron emails it to the account that owns the crontab. Most VPS builds have no mail server, so that email is never delivered and never bounces back to you. A job that has been failing every night for a month looks the same from outside as one that has worked perfectly.
None of this stops jobs failing. It makes the failure visible so you can act on it.
1. Send the output somewhere you will see it
Add this to the end of every cron line:
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
>> appends normal output to the file. 2>&1 sends error messages to the same place. Without the second part you keep the boring output and lose the errors.
If a mail server is running, name the recipient at the top of the crontab instead:
[email protected] 0 3 * * * /usr/local/bin/backup.sh
Cron sends mail to whoever MAILTO names. Set it to an empty string, MAILTO="", and no mail is sent at all. Leave it out and mail goes to the account that owns the crontab, which on most servers means it goes nowhere.
2. Cron does not use your shell environment
A command that works when you type it can still fail under cron, because cron does not load your shell profile. It sets only a few variables, and PATH in particular is short and has changed between distro versions. Use full paths for everything, or set the path yourself at the top of the file:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin SHELL=/bin/bash
Set SHELL if your script uses bash features, because cron defaults to plain sh.
Two rules about that file catch people out. Variables are not expanded, so PATH=$HOME/bin:$PATH and PATH=~/bin are both stored literally and will not work. And a comment cannot share a line with a command; anything after the command is treated as part of the command.
The last line needs to end with a newline
If the final line of a crontab has no line break after it, cron treats the file as broken and writes a warning to the system log. crontab -e adds it for you. Piping a file in with crontab myfile does not.
3. Escape the percent sign
This is the single most common broken cron line:
0 3 * * * /usr/local/bin/backup.sh > /var/log/backup-$(date +%F).log
Inside a crontab, % does not mean what it means in your shell. Cron turns it into a line break and sends everything after the first one to the command as typed input. The command above never runs as intended. Escape each one with a backslash:
0 3 * * * /usr/local/bin/backup.sh > /var/log/backup-$(date +\%F).log
Putting the date logic inside a script avoids the problem completely.
4. Make the script itself fail loudly
A shell script keeps going after a failed command and still exits with success, so cron sees a clean run. Start every script with this line:
#!/bin/bash set -euo pipefail
-e stops at the first command that fails. -u stops if you use a variable that was never set, which catches typos. -o pipefail makes a pipeline fail when any command in it fails. Without it, only the last command in the pipeline decides the result.
Then check the exit code by hand:
$ /usr/local/bin/backup.sh; echo "exit code: $?"Zero means success. Anything else is a failure, and some tools use specific numbers for specific problems, so read their documentation before treating every non-zero code the same way.
5. Stop two runs overlapping
A job that usually takes two minutes will one day take twenty, and cron starts the next one on schedule regardless. Two copies writing to the same files at once can corrupt them. flock prevents the second run from starting:
*/5 * * * * /usr/bin/flock -n /tmp/backup.lock /usr/local/bin/backup.sh
-n tells flock to give up straight away instead of waiting, so the second run exits with code 1 and the first one carries on undisturbed.
6. Get told when it breaks
A log file only helps if you read it. A systemd timer can run the same job and tell you when it fails, because systemd tracks the exit code and can start another unit in response.
[Unit] Description=Nightly backup OnFailure=alert@%n.service [Service] Type=oneshot ExecStart=/usr/local/bin/backup.sh
OnFailure names a unit to start when this one fails. %n passes the failed unit's name to it, so one alert unit can cover every job you have. The alert unit itself runs whatever you use to notify yourself:
[Unit] Description=Alert for %i [Service] Type=oneshot ExecStart=/usr/local/bin/send-alert.sh %i
Our guide on how to back up your VPS off-server with restic shows the matching timer file that decides when the job runs.
Whichever you use, check that the job is running at all:
$ grep CRON /var/log/syslog $ journalctl -u cron --since today
$ sudo tail /var/log/cron $ journalctl -u crond --since today
The service is cron on Debian and Ubuntu, crond on AlmaLinux and Rocky. Those logs record that cron started your command. They do not record whether it worked, which is what everything above is for.
Quick reference
| Task | Command |
|---|---|
| Edit your crontab | crontab -e |
| List your crontab | crontab -l |
| Edit another user's | sudo crontab -u deploy -e |
| Log all output | >> /var/log/job.log 2>&1 |
| Send mail instead | [email protected] |
| Turn mail off | MAILTO="" |
| Escape a percent sign | date +\%F |
| Prevent overlap | flock -n /tmp/job.lock command |
| Check the exit code | command; echo $? |
| See that cron ran it | journalctl -u cron --since today |
| List systemd timers | systemctl list-timers |
Where to go next
The jobs worth this effort are the ones whose absence you would not notice: backups, certificate renewal, database dumps. The last piece is a monitor that expects a message from each job and warns you when one stops arriving. Our guide on how to monitor your servers with Uptime Kuma sets that up.