Cron runs commands on a schedule: backups at 02:00, a Laravel scheduler every minute, a certificate renewal check daily. It has worked the same way for forty years, which is why the syntax looks strange and why it fails silently unless you tell it not to.
The syntax
A cron line is five time fields and a command:
┌───────── minute (0–59)
│ ┌─────── hour (0–23)
│ │ ┌───── day of month (1–31)
│ │ │ ┌─── month (1–12)
│ │ │ │ ┌─ day of week (0–7, 0 and 7 are Sunday)
│ │ │ │ │
* * * * * command* means every. Examples:
0 2 * * * /usr/local/bin/backup.sh # 02:00 every day
*/15 * * * * /usr/bin/php /var/www/app/cron.php # every 15 minutes
0 */6 * * * /usr/local/bin/sync.sh # every 6 hours
30 3 * * 0 /usr/local/bin/weekly.sh # 03:30 on Sundays
0 0 1 * * /usr/local/bin/monthly.sh # midnight on the 1st
* * * * * cd /var/www/app && php artisan schedule:run # Laravel@daily, @hourly, @weekly and @reboot are accepted shorthands. crontab.guru explains any line in English; use it before saving anything you are unsure of.
Where jobs live
Per user: crontab -e opens your own crontab; jobs run as you. crontab -l lists it. Run sudo crontab -e for root's.
System-wide: /etc/cron.d/ holds files with an extra user field between the time and the command:
0 2 * * * www-data /usr/local/bin/backup.shThis is the better place for jobs that belong to a server rather than a person — they survive user changes and are visible in one directory.
Plesk: each subscription has Scheduled Tasks in the panel, which writes the crontab for that subscription's user and offers email notification. See cron jobs in Plesk.
Three things that catch everyone
Cron's PATH is tiny. A command that works in your shell may fail in cron because php or node is not found. Use full paths (/usr/bin/php), or set PATH= at the top of the crontab.
No % unescaped. Cron treats % as a newline. date +%F in a cron line must be date +\%F.
It runs from your home directory. Scripts that expect to be in the project directory need cd /path && ....
Capture the output
By default cron mails any output to the local user, which on most servers means nowhere. Send it somewhere you will see:
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1>> appends stdout to a file; 2>&1 sends errors to the same place. Now tail /var/log/backup.log shows what happened last night. Rotate that log like any other.
Know when a job fails
This is the part most setups skip. A backup job that has been failing for three months is worse than no backup job, because you believed you had backups.
Email on failure only — the script exits non-zero when something goes wrong, and cron mails you only then:
MAILTO=you@example.com
0 2 * * * /usr/local/bin/backup.sh > /dev/null || echo "backup failed on $(hostname)"Requires a working local mail setup (apt install msmtp or a configured Postfix).
A dead man's switch — the job pings a URL when it succeeds; the service alerts you if the ping stops coming. Healthchecks.io and Cronitor both have free tiers:
0 2 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 https://hc-ping.com/your-uuid > /dev/nullThis catches every failure mode including the server being off, which email-on-failure cannot.
Check it ran — grep CRON /var/log/syslog (Ubuntu) or journalctl -u cron shows each invocation with the command.
Locking
A job that takes longer than its interval starts overlapping itself — two backups writing the same file, two queue workers processing the same item. Wrap it in flock:
*/5 * * * * flock -n /tmp/sync.lock /usr/local/bin/sync.sh-n skips the run if the previous one still holds the lock.
systemd timers
On systemd servers, a timer unit does the same job with logging built into journalctl and no PATH surprises. It is more files for the same result; use it when you want the run history, cron when you want one line. Both are covered in systemd basics.
On a VPSPioneer managed VPS, we set up backup and maintenance jobs with the dead-man's-switch pattern above, so a job that stops running is something we hear about the same day.