Schedules: cron and crontab
How to run jobs on a schedule: the five-field crontab format, common mistakes, and where the output goes.
A backup at night, cleaning up temp files once an hour, a report every Monday. Jobs like these should run on their own, on a schedule, with nobody at the terminal. The classic tool for this is cron: a daemon (a background process) that reads schedules and runs commands at the right minute.
cron is on almost every Linux server. It's simple, and that's exactly why it's easy to get wrong in a way that leaves a job silently broken for years. This lesson is about the schedule format, and about why a script that works in the terminal fails under cron.
Where to practice
Every example below assumes a ubuntu:24.04 container with the
cron package. cron isn't installed in a plain
Ubuntu image, so this lesson's labs build their own image and run the
daemon as the container's main process. On your own Ubuntu or Debian
machine, it's most likely already there: crontab -l will answer either
with a list of jobs or with no crontab for <name>.
macOS has crontab too, but Apple recommends launchd instead, and the
details differ. Practice in the labs or in WSL2.
crontab: a user's schedule
Every user has their own schedule, called a crontab. Jobs in it run with that user's permissions. You work with it through the command of the same name:
crontab -l # show your jobs
crontab -e # open your crontab in an editor
crontab -r # delete your whole crontab
crontab -e opens the editor from the EDITOR variable (in the labs
that's nano). If there's no crontab yet, you'll see a header of comments.
cron skips lines starting with #; add your own jobs at the end. After you
save, the command answers:
crontab: installing new crontab
No need to restart cron: the daemon notices changes on its own and picks them up by the next minute.
Be careful with crontab -r. The letter r sits next to e on the
keyboard, and the command wipes the whole schedule without asking:
crontab -r
crontab -l
no crontab for app
Root can work with someone else's schedule via -u: crontab -u app -l.
Format: five fields
A crontab line is five time fields and a command. The fields are separated by spaces:
┌── minute (0-59)
│ ┌── hour (0-23)
│ │ ┌── day of month (1-31)
│ │ │ ┌── month (1-12 or jan, feb, ...)
│ │ │ │ ┌── day of week (0-7, 0 and 7 = Sunday, or sun, mon, ...)
│ │ │ │ │
* * * * * command
Order matters: minute first, then hour. The line 0 3 * * * means 03:00,
while 3 0 * * * means 00:03.
What you can write in a field:
*: any value;- a number:
5in the minute field means "at minute 5"; - a comma-separated list:
0,30means "at minute 0 and minute 30"; - a range with a dash:
9-17in the hour field means "from 9 to 17 o'clock, inclusive"; - a step with
/:*/15in the minute field means "every 15 minutes" (0, 15, 30, 45). A step also works with a range:9-17/2in the hour field is 9, 11, 13, 15, and 17.
Examples:
0 3 * * * /usr/local/bin/backup.sh # every day at 03:00
*/15 * * * * /usr/local/bin/check.sh # every 15 minutes
0 9 * * 1 /usr/local/bin/report.sh # Mondays at 09:00
0 0 1 * * /usr/local/bin/monthly.sh # midnight on the 1st
0 9-17 * * * /usr/local/bin/ping.sh # every hour from 09:00 to 17:00
One subtlety about days. If both the day of month and the day of week are
set (neither is *), cron runs the job when at least one of them
matches. The line 0 0 1 * 1 fires on the 1st of every month, and also on
every Monday. If you only want specific days of the week, leave the day of
month as *.
cron takes its time from the server's time zone. Servers and containers
often live in UTC, so 0 3 * * * is 06:00 at UTC+3. Check the server's
time with date:
date
Fri Oct 9 09:51:48 UTC 2026
How cron checks a line
It doesn't, and that's an unpleasant surprise. crontab on Ubuntu 24.04
saves a line with an error without a single word of complaint: both
61 * * * * and a line with only four fields. For example:
crontab -l
* * * * * date >> /tmp/good.txt
* * * * echo four >> /tmp/four.txt
A minute later, /tmp/good.txt showed up, but /tmp/four.txt didn't. The
second line has only four fields; cron took the word echo for the day of
week and silently skipped the whole line. The neighboring lines still work.
So after any edit, always read back crontab -l and check that the job
actually ran.
There's one more character with a special meaning: %. In a crontab line
it means a newline, and everything after the first % goes to the
command's standard input instead of the command line. The job
date +%F > /tmp/d.txt won't even create the file under cron. In crontab,
% is escaped with a backslash (date +\%F), or the command is moved into
a script: inside a script, % is an ordinary character.
The job's environment
cron doesn't run a job from your terminal; it runs it itself, through
/bin/sh. The job gets a stripped-down environment. Here's what user
app's job sees in the lab (the line * * * * * env > /tmp/env.txt):
HOME=/home/app
LOGNAME=app
PATH=/usr/bin:/bin
LANG=C.UTF-8
SHELL=/bin/sh
PWD=/home/app
Three rules follow from this.
- PATH is short. It has no
/usr/local/bin,/usr/sbin, or anything you added. A command the terminal finds givesnot foundunder cron. Write full paths (/usr/local/bin/tool, nottool), or setPATHat the top of the crontab. - The profile isn't read. The job won't see
~/.bashrc,~/.profile, or anything you exported there. Set the variables you need in the crontab or in the script itself. - The current directory is the home directory. The job starts in
$HOME, not wherever the script lives. Relative paths (./data/file.csv) point somewhere else.
Variables in a crontab are set as a NAME=value line, before the jobs that
need them:
PATH=/opt/tools/bin:/usr/bin:/bin
DATA_DIR=/srv/data
0 * * * * /opt/tools/bin/sync.sh >> /var/log/sync.log 2>&1
The value is taken literally; there's no substitution. PATH=$PATH:/opt/tools/bin
turns into the literal string $PATH:/opt/tools/bin, and the job loses
even /usr/bin. After a line like that, the job fails on the
very first ordinary command with not found.
Where the output goes
Whatever a job prints to stdout and stderr, cron tries to mail to the
crontab's owner. There's usually no mail system (MTA) on the server, and
the output is discarded. On Ubuntu, cron logs a message to the system
journal at that point, No MTA installed, discarding output, but the
system journal isn't set up everywhere either. In the end, the job's errors
are nowhere to be seen.
So the output gets redirected to a log file:
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
>> file appends stdout to the end of the log, 2>&1 sends stderr
wherever stdout is currently pointing. Order matters (covered in detail in
the lesson on pipes and redirects). 2>&1 >> file sends stderr to the old
stdout, meaning the mail that disappears, and only the normal output lands
in the log.
The job's user needs permission to write to the log file. The app job
won't be able to append to /var/log/backup.log if root owns the file. The
redirection error goes nowhere too.
System schedules
Besides users' crontabs there are system ones: the file /etc/crontab and
files in /etc/cron.d/. You edit them with an ordinary editor, not the
crontab command, and they have one more field between the time and the
command: the username.
17 * * * * root cd / && run-parts --report /etc/cron.hourly
A line like this from /etc/crontab is exactly what runs the scripts from
/etc/cron.daily/, /etc/cron.weekly/, and similar folders. Packages drop
their jobs there (on Ubuntu, for example, apt-compat and dpkg in
cron.daily).
systemd timers as an alternative
On hosts with systemd, timers play the same role: a .timer unit runs
a .service unit of the same name on a schedule. Example for a backup
every day at 03:00:
# /etc/systemd/system/backup.timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
How this differs from cron:
- the service's output lands in journald; view it with
journalctl -u backup.service; - with
Persistent=true, a missed run (the server was off at 03:00) fires right after boot. Without it, the miss is lost, same as with cron; - all timers and their next run time show up in
systemctl list-timers; - an
OnCalendar=expression can be checked ahead of time withsystemd-analyze calendar '<expression>', which shows the next run.
cron is simpler and is almost everywhere, including minimal systems. timers need systemd and two files, but give you logs, dependencies, and resource limits out of the box. The unit files themselves were covered in the systemd lesson.
Common mistakes
- Minute and hour swapped.
45 1means 01:45. crontab will save the line1 45 * * *without complaint, but there's no hour 45, and the job never runs. - Step in the wrong field.
* */5 * * *means "every minute during hours 0, 5, 10, 15, and 20", not "every 5 minutes". - Command without a full path. Works in the terminal,
not foundunder cron. The cause isPATH=/usr/bin:/bin. - A variable from
~/.bashrc. It's not there under cron, and a path like"$DIR/file"turns into/file. - No redirection. The job fails, and you never find out why. The first thing to do with any cron problem is turn on logging.
%in the command.date +%Fin a crontab line truncates the command. Escape it or move it into a script.- The job runs as root instead of the user. It works, but the files end up owned by root, and the app can't overwrite them later.
- Time zone. The schedule is in UTC, and you counted in local time.
What you'll need for the tasks
- Log in to the "server" as a user:
docker exec -it -u app <name> bash, exit withexit. crontab -landcrontab -e, the nano editor: save with Ctrl+O and Enter, quit with Ctrl+X.- Write a schedule as five fields: a number,
*, a list, a range, a step. - Append a job's output and errors to a log with
>> file 2>&1and read it withcatortail. - Set
PATHand your own variables at the top of the crontab. - Remember that cron runs jobs at the start of a minute: wait a minute after an edit.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress