Scheduling Tasks with cron
cron is the standard time-based job scheduler on Linux. It runs commands and scripts automatically according to a schedule you define — every night at 2 AM, every 15 minutes, once at system boot, or any pattern you can express with five time fields — without anyone needing to be logged in or remember to run it by hand. Servers rely on cron for log rotation, backups, certificate renewal, health checks, and cleanup jobs that must happen reliably and unattended. This lesson covers the crontab syntax in full, how the cron daemon actually finds and runs your jobs, the system-wide cron files, and the environment quirks that catch almost everyone the first time they schedule something.
Overview / How cron Works
The cron daemon and personal crontabs
On a standard Ubuntu or Debian system, a background daemon called cron (on RHEL/Fedora the process is crond) starts at boot and runs continuously. Once a minute, it wakes up and compares every job it knows about to the current minute, hour, day of month, month, and day of week. Any job whose fields match the current time is run: cron forks a child process, execs a shell — by default /bin/sh, unless the crontab sets a SHELL variable — and hands that shell your command line to execute.
Each user can have a personal crontab, stored under /var/spool/cron/crontabs/<username>. You never edit that file directly; you manage it with the crontab command, which parses and validates your input before installing it, so a broken line is rejected immediately instead of silently breaking the daemon.
System-wide jobs: /etc/crontab and /etc/cron.d
Besides personal crontabs, there is one system-wide file, /etc/crontab, and a directory, /etc/cron.d/, both of which have one extra field compared to a personal crontab: the user the command should run as. This is how installed packages add their own scheduled jobs without touching any individual user’s crontab — a package can drop a file into /etc/cron.d/ during installation. Separately, /etc/cron.daily/, /etc/cron.hourly/, /etc/cron.weekly/, and /etc/cron.monthly/ hold executable scripts that get run by run-parts at the matching interval — this is where distro maintenance tasks like log rotation and package cache cleanup usually live. If a machine might be powered off at the scheduled time, anacron catches up on daily/weekly/monthly jobs after the fact; plain cron does not — a missed minute is simply skipped forever.
Day-of-month vs. day-of-week: OR, not AND
A subtlety that surprises a lot of people: when both the day-of-month and day-of-week fields are restricted (not *), cron treats them as an OR, not an AND. The line below runs at 9 AM on the 1st of the month and on every Monday — not only when the 1st happens to fall on a Monday:
0 9 1 * 1 /home/ubuntu/scripts/monthly_report.sh
If you actually need "only when the 1st is a Monday," cron can’t express that directly — you schedule it daily and have the script itself check the date with date +%u before doing anything.
Cron’s environment is not your login shell
Cron jobs run in a minimal, non-interactive environment. Your .bashrc, .bash_profile, and shell aliases are never sourced, and PATH is typically just /usr/bin:/bin. Commands that work fine when you type them at your own prompt can fail inside cron simply because the shell can’t find them. Job output (stdout and stderr) is captured and, unless redirected, mailed to the crontab owner through the local mail system; a MAILTO variable at the top of the crontab can send that mail elsewhere, and MAILTO="" disables it entirely. Which users are even allowed to have a crontab is controlled by /etc/cron.allow and /etc/cron.deny (if neither file exists, most distros allow every user by default).
Syntax
Manage your own crontab with the crontab command — never edit the spool file directly:
crontab -e
crontab -l
crontab -r
crontab -e— open your crontab in$EDITOR(falling back tonanoorvi) and install it on save; syntax is checked before it’s accepted.crontab -l— list your current crontab.crontab -r— delete your entire crontab immediately, with no confirmation and no undo.crontab -u <username> -e— edit another user’s crontab; requiressudo.
Each line in a crontab is a blank line, a comment starting with #, a VAR=value assignment, or a job with five time fields followed by the command:
<minute> <hour> <day-of-month> <month> <day-of-week> <command>
| Field | Allowed values |
|---|---|
| Minute | 0-59 |
| Hour | 0-23 |
| Day of month | 1-31 |
| Month | 1-12 (or jan-dec) |
| Day of week | 0-7 (0 and 7 are both Sunday, or sun-sat) |
Every field also accepts these special characters:
*— any value.,— a list, e.g.1,15means the 1st and the 15th.-— a range, e.g.9-17means 9 through 17./— a step, e.g.*/15in the minute field means every 15 minutes.
Instead of five fields, you can use one of these shortcuts:
| Shortcut | Equivalent to | Meaning |
|---|---|---|
@reboot |
— | Run once, at system startup |
@yearly / @annually |
0 0 1 1 * |
Once a year, Jan 1 at midnight |
@monthly |
0 0 1 * * |
Once a month, on the 1st at midnight |
@weekly |
0 0 * * 0 |
Once a week, Sunday at midnight |
@daily / @midnight |
0 0 * * * |
Once a day, at midnight |
@hourly |
0 * * * * |
Once an hour, at minute 0 |
Examples
Example 1: a nightly backup
Run crontab -e and add this line to back up a project directory every night at 2:30 AM, logging both stdout and stderr:
30 2 * * * /home/ubuntu/scripts/backup.sh >> /home/ubuntu/logs/backup.log 2>&1
Save and exit; crontab installs it right away — no restart needed. Confirm it’s there with crontab -l:
Output:
# m h dom mon dow command
30 2 * * * /home/ubuntu/scripts/backup.sh >> /home/ubuntu/logs/backup.log 2>&1
At 2:30 every morning, cron forks a shell, runs backup.sh, and appends everything the script prints to backup.log instead of mailing it, so you get a permanent, timestamp-free record you can grep later.
Example 2: logging disk usage every 15 minutes
Rather than cramming logic into the crontab line itself, put it in a small script — it’s easier to test and to read later:
#!/usr/bin/env bash
set -euo pipefail
echo "$(date '+%F %T') $(df -h / | tail -n 1)" >> /var/log/disk-usage.log
Make it executable and reference it with a step value so it runs every 15 minutes:
*/15 * * * * /usr/local/bin/log-disk-usage.sh
Output (a line appended to /var/log/disk-usage.log every 15 minutes):
2026-08-04 14:15:01 /dev/sda1 50G 32G 16G 67% /
*/15 in the minute field matches minute 0, 15, 30, and 45 of every hour, so the script runs four times an hour, every hour, forever.
Example 3: starting a service at boot
@reboot runs a job exactly once, right after cron itself starts during boot — useful for a monitoring agent or a cleanup step that only needs to happen at startup:
@reboot /home/ubuntu/scripts/start-monitor.sh >> /home/ubuntu/logs/monitor.log 2>&1
Unlike a systemd service, cron doesn’t restart this job if it crashes and doesn’t wait for the network or other services to be ready — if the script depends on something that starts later in boot, it needs to handle that itself (for example, by retrying in a loop).
How it works step by step
Every minute, the following happens inside the cron daemon:
- The daemon wakes up and checks whether any crontab files changed since it last read them; if so, it reloads them — this is why
crontab -etakes effect immediately, with no need to restart the service. - It compares the five time fields of every job to the current minute, hour, day, month, and weekday, applying the OR rule described above when both day-of-month and day-of-week are restricted.
- For each job that matches, cron forks a child process and execs the configured shell, passing it the command along with cron’s minimal environment plus any
VAR=valuelines defined at the top of that crontab. - The shell runs your command to completion (or cron moves on without waiting, since jobs run independently and in parallel with each other).
- Anything the command writes to stdout or stderr is captured. If it’s non-empty and you haven’t redirected it yourself, cron mails it to
MAILTO(or the crontab owner) via the local mail transfer agent. - Cron logs that it ran the job — command, user, and process ID — to the system log, visible with
grep CRON /var/log/syslogorjournalctl -u cron, regardless of whether the job redirected its own output. - Cron does not look at the command’s exit status and will not retry a failed job or alert you specially when one fails — from cron’s point of view, a job that exits 1 and prints nothing looks identical to one that succeeded silently.
Common Mistakes
Assuming your login PATH
This works when you run it yourself but fails from cron with "command not found", because cron’s PATH doesn’t include the directories your interactive shell adds:
# Works fine when you type it yourself, fails from cron with "python3: command not found"
*/5 * * * * python3 /home/ubuntu/scripts/check_status.py
Use an absolute path to the interpreter (find it with which python3) instead of relying on PATH:
*/5 * * * * /usr/bin/python3 /home/ubuntu/scripts/check_status.py
Forgetting to escape % inside a command
Cron treats an unescaped % in the command field as a newline — everything after it is fed to the command as standard input instead of being part of the command line. A date format string full of % characters breaks silently:
0 3 * * * /usr/bin/tar -czf /backups/db-$(date +%Y%m%d).tar.gz /var/lib/db
Escape every literal % with a backslash, and quote the substitution so the resulting path is treated as one argument:
0 3 * * * /usr/bin/tar -czf "/backups/db-$(date +\%Y\%m\%d).tar.gz" /var/lib/db
Not redirecting output
Leaving output unredirected means every run either fills the local mail spool with noise, or vanishes entirely if the system has no mail transfer agent configured at all — on a typical minimal server, this second case is far more common, so failures go completely unnoticed:
*/10 * * * * /home/ubuntu/scripts/health_check.sh
Redirect stdout and stderr to a log file you control, using >> to append rather than >, which would truncate the log on every run:
*/10 * * * * /home/ubuntu/scripts/health_check.sh >> /home/ubuntu/logs/health_check.log 2>&1
Forgetting chmod +x on the script
If the script cron is pointing at isn’t executable, the job fails immediately and, if you haven’t redirected output, the only trace is a mail like this:
/bin/sh: 1: /home/ubuntu/scripts/deploy-check.sh: Permission denied
The fix is the same one you’d use outside of cron — make the script executable before you ever reference it in a crontab:
chmod +x /home/ubuntu/scripts/deploy-check.sh
Best Practices
- Use absolute paths for both the command and every file it touches — cron’s
PATHis minimal and its working directory usually isn’t what you expect. - Always redirect output explicitly with
>> logfile 2>&1instead of relying on mail, which may not even be configured on the box. - Set
SHELL,PATH, andMAILTOat the top of a crontab when jobs need more than the bare-bones default environment:
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO="ops@example.com"
0 2 * * * backup.sh
- For jobs that might run long, guard against overlapping runs with
flockso a slow run doesn’t stack up with the next scheduled one:
*/5 * * * * /usr/bin/flock -n /tmp/health_check.lock /home/ubuntu/scripts/health_check.sh >> /home/ubuntu/logs/health_check.log 2>&1
- Keep the crontab line thin — put real logic in a script you can test on its own, not inline in the crontab entry.
- Test a script’s behavior under cron-like conditions with
env -i /bin/bash -c '/path/to/script.sh'before scheduling it, so a missingPATHor environment variable surfaces before it’s live. - Comment your crontab — a schedule that made sense when you wrote it rarely explains itself six months later.
- Audit what’s actually scheduled periodically with
crontab -l(andsudo crontab -l -u <user>for other accounts) rather than trusting memory. - For jobs with real dependency ordering or richer logging needs, look at systemd timers as an alternative — cron remains the simplest, most portable choice for straightforward scheduled tasks.
Practice Exercises
- You have a script at
~/scripts/db-backup.shthat dumps a database. Schedule it to run every day at 01:00, appending both stdout and stderr to~/logs/db-backup.log. Confirm it installed correctly. - Add a job that starts
~/scripts/start-monitor.shautomatically every time the machine boots. What do you need to verify about the script before it will actually run under cron? - A teammate says a cron job "works when I run it by hand but does nothing when cron runs it, and there’s no error email." List at least three things you’d check, in order, to track down the cause.
Summary
- cron runs jobs from personal crontabs (managed with
crontab -e) and system-wide files in/etc/crontaband/etc/cron.d, checking all of them every minute. - A job line has five time fields — minute, hour, day-of-month, month, day-of-week — followed by the command, using
*,,,-,/, or shortcuts like@dailyand@reboot. - When both day-of-month and day-of-week are restricted, cron ORs them together rather than requiring both to match.
- Cron jobs run in a minimal, non-login environment — no
.bashrc, and a short defaultPATH— so use absolute paths or setPATH/SHELLexplicitly. - Unredirected output is mailed via
MAILTOif a mail system exists, and silently discarded if it doesn’t; redirect intentionally with>>and2>&1. - A literal
%in a crontab command must be escaped as\%, since an unescaped one is read as a newline. crontab -rdeletes your whole crontab instantly with no confirmation — treat it with the same caution asrm -rf.- Cron never checks exit status or retries failures on its own — build your own logging or monitoring if a job’s success actually matters.
