Bash · lesson 18 of 22
Also part of: Linux
Cron and Scheduling in Bash
Crontab syntax, cron expressions, at for one-time jobs, systemd timers and logging cron output, with real cron and systemd runs.
By SkillByExample · Updated
Cron is the standard Unix job scheduler. It runs commands at set times: backups at 2 AM,
health checks every 5 minutes, reports every Monday. Every example below ran in an
ubuntu:24.04 container booted with systemd 255 as PID 1, with the cron and at
packages installed, as root, with the clock in UTC. So the jobs really fired, and the
journal is real.
Crontab Basics
Each user has one crontab. crontab -e opens it in an editor. In scripts it’s easier to
install a whole file with crontab FILE. This one runs date every minute, with the field
layout written as comments above it:
# ┌──────── 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 = Sunday, or SUN–SAT)
# │ │ │ │ │
* * * * * date >> /tmp/tick.log
$ crontab -l
no crontab for root
$ crontab jobs.cron
$ crontab -l
# ┌──────── 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 = Sunday, or SUN–SAT)
# │ │ │ │ │
* * * * * date >> /tmp/tick.log
$ sleep 125; cat /tmp/tick.log
Mon Oct 12 17:51:01 UTC 2026
Mon Oct 12 17:52:01 UTC 2026
$ crontab -r
$ crontab -l
no crontab for root
Cron checks its tables once a minute, so jobs start at about second :01, never midway
through a minute. crontab FILE replaces your whole crontab rather than adding to it. And
crontab -r deletes all of it at once, with no prompt. -r sits next to -e on the
keyboard, so keep a copy of the file in version control.
Common Cron Expressions
These are the schedules you’ll meet most often in production crontabs. Installing the file checks the syntax of every line:
# Every 5 minutes (*/5 means "every 5th")
*/5 * * * * /usr/local/bin/poll.sh
# Every day at 2:30 AM
30 2 * * * /usr/local/bin/backup.sh
# Every weekday (Mon–Fri) at 8:00 AM
0 8 * * 1-5 /usr/local/bin/workday-start.sh
# Every 15 minutes from 9:00 to 17:45 on weekdays
*/15 9-17 * * 1-5 /usr/local/bin/monitor.sh
# 8 AM, 12 PM and 6 PM
0 8,12,18 * * * /usr/local/bin/report.sh
# First day of every month at midnight
0 0 1 * * /usr/local/bin/monthly-rollup.sh
# Shortcuts: @reboot, @hourly, @daily, @weekly, @monthly, @yearly
@reboot /usr/local/bin/startup-tasks.sh
@daily /usr/local/bin/daily.sh
A one-character slip in a second file, typo.cron:
# Meant "every day at midnight", typed 24 instead of 0
0 24 * * * /usr/local/bin/daily.sh
$ crontab schedules.cron; echo "exit $?"
exit 0
$ crontab typo.cron; echo "exit $?"
"typo.cron":1: bad hour
errors in crontab file, can't install.
exit 1
$ crontab -l | head -2
# Every 5 minutes (*/5 means "every 5th")
*/5 * * * * /usr/local/bin/poll.sh
Hours run 0–23, so 24 is rejected. Notice two more things. A failed install leaves the
previous crontab in place: schedules.cron is still active. And this cron counts lines
from 0, so the error on line 2 is reported as :1. Also, 9-17 in the hour field means
“any minute in hours 9 through 17”, so */15 9-17 fires for the last time at 17:45, not 17:00.
Crontab Best Practices
The most common reason cron jobs fail silently is cron’s minimal environment: PATH is just
/usr/bin:/bin, there’s no profile and there are no aliases (Practice 1 shows this). So set
the environment at the top of the crontab. The MAILTO line below has a trailing comment:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com # email errors (requires a configured mail server)
* * * * * echo "MAILTO=[$MAILTO]" >> /tmp/env.log
$ crontab env.cron
$ sleep 65; cat /tmp/env.log
MAILTO=[ops@example.com # email errors (requires a configured mail server)]
Cron only treats # as a comment at the start of a line. On a NAME=value line,
everything after the = is the value, including the spaces and the comment. Cron would try to
send mail to that whole string. Move the comment onto its own line. The fixed crontab also
adds a job that doesn’t redirect its output, to see where that output goes:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Email errors here (requires a configured mail server)
MAILTO=ops@example.com
* * * * * echo "MAILTO=[$MAILTO]" >> /tmp/env.log
# No redirect: cron tries to email this job's output
* * * * * /usr/local/bin/backup.sh
$ crontab env-fixed.cron
$ sleep 60; cat /tmp/env.log
MAILTO=[ops@example.com # email errors (requires a configured mail server)]
MAILTO=[ops@example.com]
$ journalctl -u cron --no-pager -n 3 -o cat
pam_unix(cron:session): session opened for user root(uid=0) by root(uid=0)
(CRON) info (No MTA installed, discarding output)
pam_unix(cron:session): session closed for user root
MAILTO is now just the address. backup.sh printed three lines (two to stdout, one to stderr), but this server has no mail
transfer agent, so cron threw them away and left one info line in the journal. That’s
the normal state of a server: unless a job redirects its output, the output is gone.
A script can also take care of its own logging, so the crontab line stays short. exec with
only redirections changes where the rest of the script writes:
#!/usr/bin/env bash
# /usr/local/bin/nightly.sh
set -euo pipefail
LOG="/var/log/myapp/nightly.log"
mkdir -p "$(dirname "$LOG")"
# Redirect all further output (stdout and stderr) to the log file
exec >> "$LOG" 2>&1
echo "=== Nightly started: $(date) ==="
echo "warning: disk almost full" >&2
echo "=== Nightly finished: $(date) ==="
$ /usr/local/bin/nightly.sh
$ cat /var/log/myapp/nightly.log
=== Nightly started: Mon Oct 12 17:55:08 UTC 2026 ===
warning: disk almost full
=== Nightly finished: Mon Oct 12 17:55:08 UTC 2026 ===
Run by hand, the script prints nothing: after the exec, both streams go to the log, and
the stderr warning lands there in order. The script behaves the same way whether cron, a
systemd unit or a person starts it.
Preventing Overlapping Cron Jobs
If a job runs longer than its interval, cron starts a second copy while the first is still
running. That can corrupt data or process it twice. flock takes a lock atomically, so the
second copy can see the first and exit:
#!/usr/bin/env bash
# /usr/local/bin/locked.sh
LOCKFILE="/tmp/$(basename "$0").lock"
# Open FD 9 on the lockfile, then try to take an exclusive lock
exec 9>"$LOCKFILE"
if ! flock -n 9; then
echo "pid $$: another instance is running. Exiting." >&2
exit 0
fi
# The lock is released automatically when the script exits
echo "pid $$: running"
sleep 3
echo "pid $$: done"
$ locked.sh & sleep 1; locked.sh; wait
pid 249: running
pid 254: another instance is running. Exiting.
pid 249: done
The second copy started while the first held the lock, so flock -n (non-blocking) failed
at once. The lock belongs to file descriptor 9, which closes when the process exits, even if
it’s killed, so a crash never leaves a stale lock behind. You can also do the same thing
without touching the script, by putting it in the crontab line:
*/5 * * * * flock -n /tmp/monitor.lock /usr/local/bin/monitor.sh. Practice 2 runs it.
System-Wide Cron Directories
On Debian/Ubuntu and RHEL-based systems, packages and configuration management drop scripts
into /etc/cron.hourly, /etc/cron.daily, /etc/cron.weekly and /etc/cron.monthly
instead of editing a crontab. run-parts runs each directory, and --test lists what it
would run. Three copies of the same script are installed below, named and permissioned
differently:
$ grep run-parts /etc/crontab
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6 * * 7 root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6 1 * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
$ install -m 755 backup.sh /etc/cron.daily/myapp-backup
$ install -m 755 backup.sh /etc/cron.daily/myapp-report.sh
$ install -m 644 backup.sh /etc/cron.daily/myapp-cleanup
$ run-parts --test /etc/cron.daily
/etc/cron.daily/apt-compat
/etc/cron.daily/dpkg
/etc/cron.daily/myapp-backup
Only myapp-backup would run. Debian’s run-parts skips any name containing a dot, so
myapp-report.sh is ignored without a warning, and it skips files that aren’t executable,
so myapp-cleanup is ignored too. Run run-parts --test after every deploy into these
directories. The /etc/crontab lines also show when the directories run: daily at 06:25
and weekly on Sunday at 06:47. When anacron is installed, it takes over those runs instead.
at — One-Time Scheduled Jobs
at runs a command once, at a set time, with no permanent crontab entry. It reads the
command from standard input and needs the atd service running:
$ echo "/usr/local/bin/restart.sh" | at now + 10 minutes
warning: commands will be executed using /bin/sh
job 1 at Mon Oct 12 18:05:00 2026
$ echo "/usr/local/bin/cleanup.sh" | at midnight
warning: commands will be executed using /bin/sh
job 2 at Tue Oct 13 00:00:00 2026
$ atq
2 Tue Oct 13 00:00:00 2026 a root
1 Mon Oct 12 18:05:00 2026 a root
$ atrm 1
$ atq
2 Tue Oct 13 00:00:00 2026 a root
$ echo 'echo "ran at $(date +%T) as $(whoami)" > /tmp/at.out' | at now
warning: commands will be executed using /bin/sh
job 3 at Mon Oct 12 17:55:00 2026
$ sleep 5; cat /tmp/at.out
ran at 17:55:12 as root
at rounds every time down to the minute: job 3 is listed as 17:55:00, but it actually
ran at 17:55:12, when atd picked it up. The warning means what it says: the job runs under
/bin/sh (dash on Ubuntu), not bash. If a job needs bash syntax, have it run a bash
script. Unlike cron, at keeps the environment of the shell that scheduled it,
including PATH and the current directory. And % is safe here, because at doesn’t
treat it specially the way cron does.
systemd Timers
systemd timers are the modern alternative to cron on Linux systems running systemd. They
send output to the journal, catch up on runs missed while the machine was off, and support
monotonic timers and dependencies. A timer is a pair of units. The service
(/etc/systemd/system/myapp-backup.service) says what to run:
[Unit]
Description=MyApp Backup
After=network.target
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh
StandardOutput=journal
StandardError=journal
The timer (/etc/systemd/system/myapp-backup.timer) says when. This version has a bug that
is easy to miss:
[Unit]
Description=Run MyApp Backup daily at 2 AM
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=300 # spread load — run within a random 5-minute window
Persistent=true # run immediately on next boot if the last run was missed
[Install]
WantedBy=timers.target
$ systemctl enable --now myapp-backup.timer
Created symlink /etc/systemd/system/timers.target.wants/myapp-backup.timer → /etc/systemd/system/myapp-backup.timer.
$ systemctl show myapp-backup.timer -p RandomizedDelayUSec -p Persistent
RandomizedDelayUSec=0
Persistent=no
$ systemd-analyze verify /etc/systemd/system/myapp-backup.timer; echo "exit $?"
/etc/systemd/system/myapp-backup.timer:6: Failed to parse sec value, ignoring: 300 # spread load — run within a random 5-minute window
/etc/systemd/system/myapp-backup.timer:7: Failed to parse boolean value, ignoring: true # run immediately on next boot if the last run was missed
exit 0
The timer enabled without complaint, but systemctl show reports both settings at their
defaults: no random delay, and no catch-up after downtime. systemd unit files, like crontabs,
only allow # comments on their own line. A trailing comment becomes part of the value, so
300 # spread load… isn’t a number and true # run… isn’t a boolean. systemd
logs “ignoring” and drops the whole setting. The same two lines are in the journal from
when the unit was loaded:
$ journalctl -b --no-pager -o cat | grep 'Failed to parse' | tail -2
/etc/systemd/system/myapp-backup.timer:6: Failed to parse sec value, ignoring: 300 # spread load — run within a random 5-minute window
/etc/systemd/system/myapp-backup.timer:7: Failed to parse boolean value, ignoring: true # run immediately on next boot if the last run was missed
systemd-analyze verify still exits 0 after printing those errors, so a CI step that only
checks its exit status would pass this unit. Check its output instead (fail if it prints
anything). The fix is to put each comment on its own line:
[Unit]
Description=Run MyApp Backup daily at 2 AM
[Timer]
OnCalendar=*-*-* 02:00:00
# Spread load: run within a random 5-minute window
RandomizedDelaySec=300
# Run at the next boot if the machine was off at 2 AM
Persistent=true
[Install]
WantedBy=timers.target
$ systemd-analyze verify /etc/systemd/system/myapp-backup.timer
$ systemctl daemon-reload
$ systemctl show myapp-backup.timer -p RandomizedDelayUSec -p Persistent
RandomizedDelayUSec=5min
Persistent=yes
$ systemctl list-timers myapp-backup.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-10-13 02:02:22 UTC 8h - - myapp-backup.timer myapp-backup.service
1 timers listed.
Pass --all to see loaded but inactive timers, too.
$ systemctl start myapp-backup.service
$ journalctl -u myapp-backup.service --no-pager -n 6
Oct 12 17:57:00 724c098ae660 systemd[1]: Starting myapp-backup.service - MyApp Backup...
Oct 12 17:57:00 724c098ae660 backup.sh[452]: backup starting
Oct 12 17:57:00 724c098ae660 backup.sh[452]: disk almost full
Oct 12 17:57:00 724c098ae660 backup.sh[452]: backup done
Oct 12 17:57:00 724c098ae660 systemd[1]: myapp-backup.service: Deactivated successfully.
Oct 12 17:57:00 724c098ae660 systemd[1]: Finished myapp-backup.service - MyApp Backup.
verify is silent now, and both settings take effect. The next run is 02:02:22 rather
than 02:00:00 because the 5-minute random delay is already applied. systemctl start on the
service runs the job immediately, which is how you test it without waiting for 2 AM. Both
stdout and stderr landed in the journal, tagged with the script name and PID. No redirect
was needed.
systemd-analyze calendar shows when any OnCalendar= expression will next fire, so you
don’t have to guess:
$ systemd-analyze calendar --iterations=2 'Mon *-*-* 09:00:00' '*:0/5' 'monthly'
Normalized form: Mon *-*-* 09:00:00
Next elapse: Mon 2026-10-19 09:00:00 UTC
From now: 6 days left
Iteration #2: Mon 2026-10-26 09:00:00 UTC
From now: 1 week 6 days left
Original form: *:0/5
Normalized form: *-*-* *:00/5:00
Next elapse: Mon 2026-10-12 18:00:00 UTC
From now: 3min 13s left
Iteration #2: Mon 2026-10-12 18:05:00 UTC
From now: 8min left
Original form: monthly
Normalized form: *-*-01 00:00:00
Next elapse: Sun 2026-11-01 00:00:00 UTC
From now: 2 weeks 5 days left
Iteration #2: Tue 2026-12-01 00:00:00 UTC
From now: 1 month 18 days left
The normalized form is the full DayOfWeek Year-Month-Day Hour:Minute:Second syntax that
every shorthand expands to. *:0/5 means “every 5 minutes”, and monthly means midnight on the 1st.
Logging Cron Output
Without a redirect, cron’s output is lost, as the MAILTO example showed. Three patterns
cover most cases. Here they all run every minute against the same backup.sh, which prints
two lines to stdout and one to stderr:
# Pattern 1: redirect in the crontab line itself (simplest)
* * * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
# Pattern 2: prepend a timestamp to every output line
# (one line only, and every % escaped as \% — see below)
* * * * * /usr/local/bin/backup.sh 2>&1 | while IFS= read -r line; do echo "$(date '+\%Y-\%m-\%d \%H:\%M:\%S') $line"; done >> /var/log/backup-ts.log
# Pattern 3: send to syslog/journald via logger
* * * * * /usr/local/bin/backup.sh 2>&1 | logger -t backup -p local0.info
$ crontab logging.cron
$ sleep 65; cat /var/log/backup.log
backup starting
disk almost full
backup done
$ cat /var/log/backup-ts.log
2026-10-12 17:56:01 backup starting
2026-10-12 17:56:01 disk almost full
2026-10-12 17:56:01 backup done
$ journalctl -t backup --no-pager
Oct 12 17:56:01 724c098ae660 backup[299]: backup starting
Oct 12 17:56:01 724c098ae660 backup[299]: disk almost full
Oct 12 17:56:01 724c098ae660 backup[299]: backup done
All three captured the stderr line, because 2>&1 merges it in first. Pattern 1 has no
timestamps, so you can’t tell which run wrote which line. Pattern 3 gets timestamps and
central log shipping from journald for free.
Pattern 2 breaks two crontab rules that a shell script wouldn’t. A crontab has no line
continuation, so a \ at the end of a line doesn’t join it to the next one, and crontab
refuses the file (bad minute). And cron cuts the command at the first unescaped % and
feeds the rest to it as standard input. So date '+%Y' must be written date '+\%Y', or the
job runs a truncated command and never writes the log (Practice 3 shows exactly what runs).
Monitoring Cron Jobs
Detecting that a job has stopped running matters as much as logging what it does when it
runs. The job touches a sentinel file at the end of each successful run
(touch /var/run/myjob.lastrun), and a separate check alerts when the file gets too old:
#!/usr/bin/env bash
# check-lastrun.sh: alert if the daily job has not run within 25 hours
last_run=$(stat -c '%Y' /var/run/myjob.lastrun 2>/dev/null || echo 0)
now=$(date +%s)
age=$(( now - last_run ))
if (( age > 90000 )); then # 25 hours: catches a daily job that missed one run
echo "ALERT: myjob has not run for $(( age / 3600 )) hours" >&2
exit 1
fi
$ touch /var/run/myjob.lastrun; bash check-lastrun.sh; echo "exit $?"
exit 0
$ touch -d "30 hours ago" /var/run/myjob.lastrun; bash check-lastrun.sh; echo "exit $?"
ALERT: myjob has not run for 30 hours
exit 1
A fresh sentinel passes silently. One backdated by 30 hours fails with exit status 1, which any monitoring system can act on. Run the check from a different scheduler or host than the job itself: if cron is down, a check that cron runs is down too.
Practice
Solutions 1, 2 and 4 ran in an ubuntu:24.04 container with the cron package installed
and cron -f as the main process, so the jobs really fired. Solution 3 ran in the
systemd-booted container used for the lesson body, so it could read cron’s journal.
-
Prove the “minimal environment” claim. Install a crontab entry that runs every minute and appends
$PATHto a log. Then add thePATH=line from “Crontab Best Practices” at the top of the crontab and look again.Solution
The first crontab:
* * * * * echo "default: PATH=$PATH" >> /tmp/env.log 2>&1One minute later, replace it with:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin * * * * * echo "with PATH line: PATH=$PATH" >> /tmp/env.log 2>&1$ echo "interactive: PATH=$PATH" interactive: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin $ cat /tmp/env.log default: PATH=/usr/bin:/bin with PATH line: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binYour terminal and cron are running on the same machine, but cron’s jobs get only
/usr/bin:/bin. Anything installed in/usr/local/binwould be “command not found” until the crontab setsPATHitself. -
Make the overlap problem happen. Write
/w/slow.sh, which takes 80 seconds, schedule it every minute behindflock -n, and leave it running for four minutes.Solution
#!/usr/bin/env bash echo "$(date +%H:%M:%S) start (pid $$)" sleep 80 echo "$(date +%H:%M:%S) end (pid $$)"* * * * * flock -n /tmp/slow.lock /w/slow.sh >> /tmp/slow.log 2>&1$ cat /tmp/slow.log 10:03:01 start (pid 119) 10:04:21 end (pid 119) 10:05:01 start (pid 241) 10:06:21 end (pid 241)Cron did fire at 10:04, but the 10:03 run still held the lock, so
flock -nexited without running the script. There is no “10:04 start” line, and no two runs ever overlap. The%signs are fine here because they are inside the script, not on the crontab line. -
Take Pattern 2 from “Logging Cron Output” and remove the backslashes before every
%. Install it, wait a minute, and work out what cron actually ran.Solution
$ crontab unescaped.cron $ crontab -l * * * * * /usr/local/bin/backup.sh 2>&1 | while IFS= read -r line; do echo "$(date '+%Y-%m-%d %H:%M:%S') $line"; done >> /var/log/backup-ts.log $ sleep 65; cat /var/log/backup-ts.log cat: /var/log/backup-ts.log: No such file or directory $ journalctl -u cron --no-pager -o cat -n 3 (CRON) info (No MTA installed, discarding output) pam_unix(cron:session): session closed for user root (root) RELOAD (crontabs/root) $ crontab -l | cut -d'%' -f1 | cut -d' ' -f6- > /tmp/what-cron-ran; cat /tmp/what-cron-ran /usr/local/bin/backup.sh 2>&1 | while IFS= read -r line; do echo "$(date '+ $ sh /tmp/what-cron-ran /tmp/what-cron-ran: 2: Syntax error: Unterminated quoted stringcrontabinstalled the line without complaint, and cron fired it, but the log file was never created. Cron passed only the text before the first%to/bin/sh, which is an unclosed'quote. The shell failed with a syntax error before running anything, and that error message was the “output” cron discarded. Put the backslashes back, or move the pipeline into a script file, where%has no special meaning. -
Copy
check-lastrun.sh, point it at/tmp/myjob.lastrun, and add anelsebranch that printsOK. Test it three ways: with a fresh sentinel, with one backdated bytouch -d "26 hours ago", and with no sentinel at all.Solution
last_run=$(stat -c '%Y' /tmp/myjob.lastrun 2>/dev/null || echo 0) now=$(date +%s) age=$(( now - last_run )) if (( age > 90000 )); then echo "ALERT: myjob has not run for $(( age / 3600 )) hours" >&2 else echo "OK: myjob ran $(( age / 3600 )) hours ago" fi$ touch /tmp/myjob.lastrun; bash check-lastrun.sh OK: myjob ran 0 hours ago $ touch -d "26 hours ago" /tmp/myjob.lastrun; bash check-lastrun.sh ALERT: myjob has not run for 26 hours $ rm /tmp/myjob.lastrun; bash check-lastrun.sh ALERT: myjob has not run for 497698 hoursA missing sentinel makes the
|| echo 0fallback kick in, which counts the age from 1970. That is still an alert, which is what you want for a job that has never run.
What’s Next
The next tutorial covers Bash security — safe temp files, avoiding command injection, handling secrets, and file permissions.