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 to nano or vi) 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; requires sudo.

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,15 means the 1st and the 15th.
  • - — a range, e.g. 9-17 means 9 through 17.
  • / — a step, e.g. */15 in 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:

  1. 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 -e takes effect immediately, with no need to restart the service.
  2. 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.
  3. 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=value lines defined at the top of that crontab.
  4. The shell runs your command to completion (or cron moves on without waiting, since jobs run independently and in parallel with each other).
  5. 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.
  6. Cron logs that it ran the job — command, user, and process ID — to the system log, visible with grep CRON /var/log/syslog or journalctl -u cron, regardless of whether the job redirected its own output.
  7. 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 PATH is minimal and its working directory usually isn’t what you expect.
  • Always redirect output explicitly with >> logfile 2>&1 instead of relying on mail, which may not even be configured on the box.
  • Set SHELL, PATH, and MAILTO at 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 flock so 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 missing PATH or 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 (and sudo 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

  1. You have a script at ~/scripts/db-backup.sh that 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.
  2. Add a job that starts ~/scripts/start-monitor.sh automatically every time the machine boots. What do you need to verify about the script before it will actually run under cron?
  3. 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/crontab and /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 @daily and @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 default PATH — so use absolute paths or set PATH/SHELL explicitly.
  • Unredirected output is mailed via MAILTO if a mail system exists, and silently discarded if it doesn’t; redirect intentionally with >> and 2>&1.
  • A literal % in a crontab command must be escaped as \%, since an unescaped one is read as a newline.
  • crontab -r deletes your whole crontab instantly with no confirmation — treat it with the same caution as rm -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.