Why schedule

Much of system administration is routine: back up a database every night, clean temporary files every hour, renew a certificate, send a weekly report, check free disk space every fifteen minutes. Doing that by hand is unreliable, and it does not happen at 02:30 when nobody is at work. Linux has two standard schedulers for repeating jobs, cron and systemd timers, plus at for a job that runs once. The job itself is usually a script (see Shell Scripting Basics); the scheduler only decides when it runs.

cron and the crontab

cron is the classic Unix scheduler: a daemon that wakes up every minute, reads its schedule tables and starts every job whose time has come. The daemon is called cron on Debian and Ubuntu and crond (from the cronie package) on RHEL and Fedora; both read the same file format.

Editing your crontab

Each user has a personal schedule table, a crontab ("cron table"). You never edit the file directly; the crontab command does it for you and checks the syntax when you save:

Command Action
crontab -e Edit your crontab in your editor; cron picks up the change when you save
crontab -l List your crontab
crontab -r Remove your whole crontab, without asking
sudo crontab -u ana -l List another user's crontab (as root)
$ crontab -l
# m    h  dom mon dow  command
*/15   *  *   *   *    /usr/local/bin/check-disk.sh >> /home/ana/logs/check-disk.log 2>&1
30     2  *   *   1-5  /home/ana/bin/backup.sh >> /home/ana/logs/backup.log 2>&1
0      8  1   *   *    /home/ana/bin/monthly-report.sh >> /home/ana/logs/report.log 2>&1

Who may use crontab is controlled by /etc/cron.allow and /etc/cron.deny: if cron.allow exists, only the users listed in it may; otherwise everyone except those in cron.deny.

The five time fields

Every job line starts with five fields separated by spaces, then the command. A job runs when the current time matches all the fields.

The crontab line 30 2 star star 1-5 backup.sh split into its fields: minute 30, hour 2, day of month any, month any, day of week 1 to 5 (Monday to Friday), then the command. Result: every weekday at 02:30. 30 2 * * 1-5 /home/ana/bin/backup.sh minute 0–59 hour 0–23 day of month 1–31 month 1–12 day of week 0–7, Sun=0/7 command run by /bin/sh = at 02:30 on every Monday, Tuesday, Wednesday, Thursday and Friday
Read a crontab line from left to right: minute, hour, day of month, month, day of week, command.
Field Allowed values Notes
Minute 0–59
Hour 0–23 24-hour clock; 0 is midnight
Day of month 1–31
Month 1–12 or jan–dec
Day of week 0–7 or sun–sat 0 and 7 are both Sunday; 1 is Monday

Each field can hold more than one value:

Syntax Meaning Example
* Every value * in the hour field: every hour
, A list 0,30: on the hour and at half past
- A range 1-5: Monday to Friday
/ A step */15: every 15th value, starting at the lowest

The day-of-week numbers are the ones date +%w prints: Sunday is 0. (date +%u numbers Monday to Sunday as 1 to 7, which is why cron also accepts 7 for Sunday.)

date -d 2026-10-04 +'%A: date +%%u says %u, cron and date +%%w say %w'
date -d 2026-10-05 +'%A: date +%%u says %u, cron and date +%%w say %w'
Sunday: date +%u says 7, cron and date +%w say 0
Monday: date +%u says 1, cron and date +%w say 1

A step counts from the start of the field's range and restarts every hour, day or month. It only gives even intervals when it divides the range: */25 in the minute field is not "every 25 minutes".

echo "*/15 in the minute field: $(seq -s ' ' 0 15 59)"
echo "*/25 in the minute field: $(seq -s ' ' 0 25 59)"
echo "*/5 in the hour field:    $(seq -s ' ' 0 5 23)"
*/15 in the minute field: 0 15 30 45
*/25 in the minute field: 0 25 50
*/5 in the hour field:    0 5 10 15 20

With */25 the job runs at :00, :25 and :50, then again ten minutes later at the next :00.

Examples

Schedule Runs
*/15 * * * * Every 15 minutes (:00, :15, :30, :45)
30 2 * * 1-5 At 02:30 on weekdays
0 */4 * * * Every four hours on the hour (00:00, 04:00 … 20:00)
0 9-17 * * 1-5 Every hour from 09:00 to 17:00 on weekdays
0 8 1 * * At 08:00 on the first day of every month
0 0 * * 0 At midnight between Saturday and Sunday
0 3 1 1,4,7,10 * At 03:00 on the first day of each quarter
* * * * * Every minute (useful for testing, rarely otherwise)

Special strings

Instead of the five fields, a line can start with one of these shortcuts:

String Same as Runs
@reboot (none) Once, when cron starts after boot
@hourly 0 * * * * At the start of every hour
@daily or @midnight 0 0 * * * Every day at midnight
@weekly 0 0 * * 0 Sunday at midnight
@monthly 0 0 1 * * Midnight on the first of the month
@yearly or @annually 0 0 1 1 * Midnight on 1 January

The cron environment

The most common complaint about cron is "the script works when I run it, but not from cron". The reason is almost always the environment. A cron job does not run in your login shell:

  • A minimal PATH, typically just /usr/bin:/bin, so commands in /usr/local/bin, /usr/sbin or ~/bin are not found. Your .bashrc and .profile are not read either, so aliases and exported variables are missing.
  • /bin/sh as the shell for the command line itself (dash on Debian and Ubuntu), unless the crontab sets SHELL=/bin/bash. A script with its own shebang is not affected.
  • Your home directory as the working directory, so relative paths in a script point somewhere unexpected.
  • No terminal. Nobody can answer a prompt such as a password question or "Are you sure? [y/N]", and any output is mailed to the user if the system can send mail, or else discarded.

You can reproduce the PATH problem with env -i, which starts a command with an empty environment:

mkdir -p bin
printf '#!/bin/bash\necho "report generated"\n' > bin/make-report
chmod +x bin/make-report

PATH="$HOME/bin:$PATH"
make-report

env -i PATH=/usr/bin:/bin bash -c 'make-report'
echo "exit status $?"
env -i PATH=/usr/bin:/bin bash -c "$HOME/bin/make-report"
report generated
bash: line 1: make-report: command not found
exit status 127
report generated

The rules that follow from this:

  1. Use absolute paths for the script, the commands in it and the files it touches, or set PATH at the top of the crontab or the script.
  2. Redirect output to a log file with >> /path/job.log 2>&1, so you can see what happened. The order matters: 2>&1 must come after the file redirection, otherwise error messages still go to the old destination:
ls missing.txt >> right.log 2>&1
ls missing.txt 2>&1 >> wrong.log
echo "--- right.log:"
cat right.log
echo "--- wrong.log:"
cat wrong.log
ls: cannot access 'missing.txt': No such file or directory
--- right.log:
ls: cannot access 'missing.txt': No such file or directory
--- wrong.log:

The second ls printed its error to the terminal, and in cron that means it is lost. Two more cron-specific details:

  • In a crontab command, % means "new line". Write date +\%F, or better, move the command into a script where % needs no escaping.
  • Cron starts a job again at its next time even if the previous run is still busy. For jobs that could overlap, use flock: flock -n /tmp/backup.lock /home/ana/bin/backup.sh skips the run when another holds the lock.
exec 9> job.lock
flock -n 9 && echo "First run: got the lock, working"

flock -n job.lock echo "Second run: working" || echo "Second run: previous run still busy, skipped"

exec 9>&-
flock -n job.lock echo "Third run (after the first finished): working"
First run: got the lock, working
Second run: previous run still busy, skipped
Third run (after the first finished): working

Here the shell itself plays the "first run": it opens job.lock on file descriptor 9 and holds the lock until it closes that descriptor with exec 9>&-.

How to write a script that survives an unattended run, and the Windows equivalent, Task Scheduler, is on Scheduling and Logging.

System crontabs and anacron

Besides the personal crontabs, cron reads system-wide files that administrators and packages use. Their lines have a sixth field, the user the job runs as, between the time fields and the command.

Location Format Used for
crontab -e (per user) 5 time fields + command Your own jobs, run as you
/etc/crontab 5 time fields + user + command The main system table; mostly runs the directories below
/etc/cron.d/* 5 time fields + user + command One file per application or task; the place for your own system jobs
/etc/cron.hourly/, cron.daily/, cron.weekly/, cron.monthly/ Executable scripts, no schedule line Run as root once per hour, day, week or month
# Nightly backup of the web data, as the user backup
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=ops@example.com

30 2 * * *  backup  /usr/local/bin/backup-web.sh >> /var/log/backup-web.log 2>&1

The directories cron.daily and friends are run by the run-parts command. On Debian and Ubuntu it only runs files whose names consist of letters, digits, underscores and hyphens, so a script called backup.sh in /etc/cron.daily/ is silently skipped: name it backup. run-parts --test /etc/cron.daily lists what would run. Debian's cron likewise ignores files in /etc/cron.d/ whose names contain a dot.

anacron

Cron only runs a job if the machine is on at that minute. A laptop that is off at 06:25 simply misses the daily run. anacron fills that gap for the daily, weekly and monthly jobs: it records when each last ran and, when the machine starts or at its next check, runs any job whose period has passed. It works with periods in days, not exact times, and its table is /etc/anacrontab. On Debian and Ubuntu desktops and on RHEL (package cronie-anacron), it is what actually runs the cron.daily, weekly and monthly directories when installed.

Distribution differences

Debian / Ubuntu RHEL / Fedora
Package and service cron, cron.service cronie, crond.service
User crontabs stored in /var/spool/cron/crontabs/ /var/spool/cron/
Cron log journalctl -u cron, or /var/log/syslog where rsyslog is installed journalctl -u crond, /var/log/cron

systemd timers

A systemd timer is a unit file ending in .timer that tells systemd when to start another unit, normally a .service with the same name. The service says what to run, the timer says when. Every distribution with systemd has them, and many system jobs already use them (see Services and systemd for units in general).

[Unit]
Description=Nightly backup of the web data

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup-web.sh
[Unit]
Description=Run backup-web.service every weekday at 02:30

[Timer]
OnCalendar=Mon..Fri 02:30
Persistent=true

[Install]
WantedBy=timers.target
  • Type=oneshot: the service runs a command to completion instead of staying active. User= runs it under a normal account rather than root.
  • OnCalendar= sets the schedule in systemd's calendar format, DayOfWeek Year-Month-Day Hour:Minute:Second, where missing parts mean "any" (and a missing time means midnight). Examples: daily (every day at 00:00), Mon..Fri 02:30, *:0/15 (every 15 minutes), *-*-01 08:00 (the first of every month).
  • Persistent=true: systemd remembers when the timer last fired. If the machine was off at the scheduled time, the job runs soon after the next boot, like anacron.
  • WantedBy=timers.target: enabling the timer makes it start at every boot.

Other timer settings you will meet: OnBootSec=10min and OnUnitActiveSec=1h (relative to boot or to the last run), and RandomizedDelaySec=, which spreads jobs of many machines so they do not all hit a server at once. By default systemd may start a job up to one minute late (AccuracySec=1min) to save power by grouping wake-ups.

Activate the timer, not the service:

$ sudo systemctl daemon-reload
$ sudo systemctl enable --now backup-web.timer
$ systemctl list-timers backup-web.timer
NEXT                        LEFT     LAST PASSED UNIT             ACTIVATES
Fri 2026-10-02 02:30:00 UTC 15h left -    -      backup-web.timer backup-web.service

1 timers listed.

systemctl list-timers without a name shows every active timer with its next and previous run. Test the job itself by starting the service by hand with sudo systemctl start backup-web.service, and read its output with journalctl -u backup-web.service: every line the script prints ends up in the journal, with no redirection needed. Users can have their own timers too, in ~/.config/systemd/user/, managed with systemctl --user.

cron or a systemd timer?

cron systemd timer
Setup One line in a crontab Two unit files, then enable --now
Output and logging Mailed or lost unless you redirect it Automatically in the journal
Missed runs (machine off) Skipped; anacron covers daily and longer Persistent=true catches up
Overlapping runs Starts another copy; use flock Does not start the service again while it is still running
Environment Minimal, set in the crontab Clean and explicit, set in the unit
Extras Works on any Unix-like system Dependencies, resource limits, random delays, a manual start of the same job for testing

Cron is quick and universal, and every administrator can read a crontab. Timers take a little more typing and give you logging, catch-up and the rest of systemd for free. For new system jobs on a systemd distribution, timers are increasingly the default; for a quick personal job, cron is still fine.

One-off jobs with at

The at command runs commands once, at a later time: restart a service tonight during the maintenance window, or remind yourself to remove a temporary firewall rule. You type the commands at the at> prompt and finish with Ctrl+D. It needs the atd service, which is installed with the at package and not always present by default.

$ at 22:00
warning: commands will be executed using /bin/sh
at> /usr/local/bin/rotate-reports.sh
at> <EOT>
job 7 at Thu Oct  1 22:00:00 2026
$ echo "/usr/local/bin/cleanup-tmp.sh" | at now + 2 hours
job 8 at Thu Oct  1 13:30:00 2026
$ atq
7	Thu Oct  1 22:00:00 2026 a ana
8	Thu Oct  1 13:30:00 2026 a ana
$ atrm 8

at understands times such as 22:00, noon tomorrow, now + 30 minutes and 02:00 2026-10-15. atq lists your pending jobs, atrm removes one by number, and at -c 7 shows exactly what job 7 will run. Like cron, at jobs run without a terminal and mail their output. Unlike cron, they keep the environment and working directory from the moment you created them. The related batch command runs a job as soon as the system load is low.

Testing a schedule

A schedule that is wrong fails silently: the job just does not run, or runs at the wrong time. Test in steps:

  1. Check the expression. Websites such as crontab.guru translate a cron expression into words. For systemd, systemd-analyze calendar normalizes an OnCalendar value and shows the next time it matches:
$ systemd-analyze calendar 'Mon..Fri 02:30'
  Original form: Mon..Fri 02:30
Normalized form: Mon..Fri *-*-* 02:30:00
    Next elapse: Fri 2026-10-02 02:30:00 UTC
       From now: 15h left
  1. Run the command by hand the way the scheduler will: with its absolute path, as the same user, ideally with env -i as above. For a timer, start the service with systemctl start.
  2. Try the schedule with a nearby time (two minutes from now), then set the real time.
  3. Check that it ran: the job's own log file, the cron entries in the journal or /var/log/cron, or journalctl -u name.service for a timer (see Logs and Monitoring). A job that silently stops running produces no error at all; let monitoring alert when its last success is too old (see jobs that are not request-driven).

Remember the time zone: cron and timers use the system's time zone (timedatectl shows it). Servers often run on Coordinated Universal Time (UTC), so "02:30" may not be 02:30 on your watch. In zones with daylight saving time, the early-morning hour in which the clocks change is skipped or repeated twice a year. cron runs jobs from a skipped hour right after the change and does not repeat fixed-time jobs in a repeated hour; check man cron or systemd.time for your scheduler. The European Union (EU) and the United Kingdom (UK) change at the same moment, 01:00 UTC on the last Sunday of March and of October: in local time that is 02:00–03:00 in the Netherlands, Germany and France, 01:00–02:00 in the UK, Ireland and Portugal, and 03:00–04:00 in Finland and Greece. The United States changes at 02:00 local time in each zone on the second Sunday of March and the first Sunday of November (Hawaii, most of Arizona and US territories such as Puerto Rico and Guam do not change at all). Either way the hour falls exactly where many nightly jobs sit. The US House passed the Sunshine Protection Act (H.R. 139), which would make daylight saving time permanent, on 14 July 2026; as of October 2026 the Senate has not voted on it, so check its status. Servers set to UTC, which has no daylight saving time, avoid the question entirely.