Scheduling Tasks
Running commands automatically: cron and crontab syntax, systemd timers, and one-off jobs with at.
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.
| 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/sbinor~/binare not found. Your.bashrcand.profileare not read either, so aliases and exported variables are missing. -
/bin/shas the shell for the command line itself (dash on Debian and Ubuntu), unless the crontab setsSHELL=/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:
-
Use absolute paths for the script, the commands
in it and the files it touches, or set
PATHat the top of the crontab or the script. -
Redirect output to a log file with
>> /path/job.log 2>&1, so you can see what happened. The order matters:2>&1must 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". Writedate +\%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.shskips 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:
-
Check the expression. Websites such as
crontab.guru translate a cron expression into words. For systemd,
systemd-analyze calendarnormalizes anOnCalendarvalue 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
-
Run the command by hand the way the scheduler will: with its absolute path, as the same user, ideally with
env -ias above. For a timer, start the service withsystemctl start. - Try the schedule with a nearby time (two minutes from now), then set the real time.
-
Check that it ran: the job's own log file, the
cron entries in the journal or
/var/log/cron, orjournalctl -u name.servicefor 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.