07 · Scheduling & Automation¶
Scripts are most useful when they run themselves. This module covers
cron for recurring jobs and at for one-off scheduled jobs — the two
standard Unix ways to run a script "later" without a human triggering it.
The crontab¶
crontab -l # list the current user's cron jobs
crontab -e # edit them (opens $EDITOR)
crontab -r # remove ALL of the current user's cron jobs (careful!)
Crontab syntax¶
# every day at 2:30 AM
30 2 * * * /home/user/scripts/backup.sh
# every 15 minutes
*/15 * * * * /home/user/scripts/healthcheck.sh
# every weekday (Mon-Fri) at 9am
0 9 * * 1-5 /home/user/scripts/report.sh
# at midnight on the 1st of every month
0 0 1 * * /home/user/scripts/monthly_cleanup.sh
# every Sunday at 3am
0 3 * * 0 /home/user/scripts/weekly_archive.sh
| Field | Range | Examples |
|---|---|---|
| minute | 0-59 | 0, */15, 30 |
| hour | 0-23 | 2, 9, */6 |
| day of month | 1-31 | 1, 15, * |
| month | 1-12 | 6, *, 1,7 |
| day of week | 0-6 (Sun=0) | 1-5, 0, * |
* means "every value," */N means "every N units," and comma lists
(1,15) or ranges (1-5) can combine within a field.
Writing cron-safe scripts¶
Cron jobs run with a minimal environment (no interactive $PATH, no
.bashrc loaded), so scripts that work fine in your terminal can fail
silently under cron. Defensive habits:
#!/usr/bin/env bash
set -euo pipefail
# don't rely on relative paths or an inherited PATH
export PATH="/usr/local/bin:/usr/bin:/bin"
cd "$(dirname "${BASH_SOURCE[0]}")" # always work from the script's own directory
# use absolute paths everywhere
LOG_FILE="/var/log/myjob.log"
DATA_DIR="/home/user/data"
# always log — cron's own error output often gets emailed or silently dropped
0 2 * * * /home/user/scripts/backup.sh >> /home/user/logs/backup.log 2>&1
Redirecting both stdout and stderr to a log file (rather than leaving cron
to handle it) means you always have a record of what happened, and can
tail -f it while debugging a job that "isn't working."
at — running a command once, at a specific time¶
at 10:00 PM <<< "/home/user/scripts/cleanup.sh"
at now + 30 minutes <<< "echo reminder: check the deploy"
at 2026-07-20 09:00 <<< "/home/user/scripts/monthly_report.sh"
atq # list pending `at` jobs
atrm 3 # cancel job number 3 (from `atq`'s output)
cron is for recurring schedules; at is for a single future run —
useful for "run this once, tonight, after traffic dies down" tasks that
don't belong in the crontab permanently.
Checking whether cron actually ran your job¶
grep CRON /var/log/syslog | tail -20 # Debian/Ubuntu
grep cron /var/log/system.log | tail -20 # macOS
crontab -l | grep backup.sh # confirm the job is actually installed
A locking pattern to avoid overlapping runs¶
#!/usr/bin/env bash
set -euo pipefail
LOCK_FILE="/tmp/myjob.lock"
if [[ -e "$LOCK_FILE" ]]; then
echo "already running (lock file exists) — exiting" >&2
exit 1
fi
trap 'rm -f "$LOCK_FILE"' EXIT
touch "$LOCK_FILE"
# ... the actual job ...
echo "doing scheduled work..."
If a job sometimes takes longer than its own interval (a 5-minute cron job that occasionally takes 6 minutes), this lock-file pattern prevents two copies from running at once and stepping on each other.
How It Actually Works¶
cron is a long-running daemon process, entirely separate from any
interactive shell — it wakes up roughly once a minute, re-reads crontab
files if their mtimes changed, and for any job whose schedule matches the
current time, it fork()s and execve()s a new, minimal, non-interactive
shell to run that one command line. That new shell inherits almost none of
your interactive environment: no .bashrc, a bare-bones $PATH, and no
$DISPLAY or other session variables — which is the actual root cause of
"works when I run it myself, fails under cron," not a cron bug.
A file lock pattern using mkdir (or flock) for "avoid overlapping runs"
works because mkdir(2) is atomic at the kernel/filesystem level — the
kernel guarantees that if two processes race to create the same directory
name simultaneously, exactly one mkdir call succeeds and the other gets
EEXIST, with no possibility of both seeing "doesn't exist yet." flock(2)
achieves the same guarantee more directly via an advisory lock the kernel
tracks per open file descriptor, released automatically if the holding
process dies — which is why flock-based locks self-heal after a crash
while a bare mkdir lock file can be left stale and needs manual or
timestamp-based cleanup.
Cheat sheet¶
| Command | Purpose |
|---|---|
crontab -e |
edit the current user's crontab |
crontab -l |
list current cron jobs |
* * * * * |
minute hour day-of-month month day-of-week |
*/N |
every N units of that field |
at TIME |
schedule a one-off command |
atq / atrm N |
list / cancel pending at jobs |
>> log 2>&1 |
always redirect cron job output to a log file |
Exercise¶
Write a crontab line that runs a script called disk_check.sh every 6
hours, logging output to /var/log/disk_check.log. Then write
disk_check.sh itself using the lock-file pattern above, so it exits
immediately (with a clear message) if a previous run is still in progress.