Skip to content

4. Cron and task scheduling โ€‹

๐ŸŽฏ What you'll learn in this chapter:

  • cron basics โ€” crontab, the 5-field syntax
  • The PATH problem โ€” bash's most common cron trap
  • at โ€” a one-time delayed task
  • systemd timers โ€” the modern alternative
  • flock โ€” the production pattern for a single-instance guarantee
  • A real example โ€” Nightly backup with notifications

โฑ Time: ~25 minutes ๐Ÿงช Exercises: bashlings watch โ€” 7 interactive exercises ready (exercises/14_cron/)


4.1. Why do you need scheduling? โ€‹

A server's daily automated jobs:

TaskTypical timeTool
Database backupevery nightcron / systemd
Log rotationevery daylogrotate (cron)
SSL certificate renewaltwice a monthcertbot (cron)
Cache cleanupevery 15 minutescron
Health check & alertevery 5 minutescron / monitor
Index rebuildon weekendscron

Core idea

Any task that runs reliably and continuously without human involvement is a scheduled task. The mantra: "If you do it more than twice a month โ€” automate it."


4.2. cron basics โ€‹

cron โ€” the classic scheduler of Unix systems (since 1975).

Core commands โ€‹

bash
crontab -l          # current list
crontab -e          # edit (opens the default editor)
crontab -r          # โš  delete EVERYTHING โ€” careful!
crontab my.cron     # import from a file
crontab -u ali -l   # another user's cron (as root)

Locations โ€‹

~/                          โ† user cron, via `crontab -e`
/var/spool/cron/<user>      โ† crons are stored here (root only)
/etc/crontab                โ† system-wide cron (with an extra user field)
/etc/cron.d/                โ† additional cron files
/etc/cron.daily/            โ† scripts run every day
/etc/cron.hourly/
/etc/cron.weekly/
/etc/cron.monthly/

crontab -r โ€” careful!

The -r flag deletes your entire cron table without asking. There is no -i flag!

Make a backup:

bash
crontab -l > ~/cron-backup-$(date +%F).txt

4.3. Cron syntax โ€” the 5 fields โ€‹

*  *  *  *  *  command
โ”‚  โ”‚  โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  โ”‚  โ””โ”€โ”€ day of week     (0-7,  0 and 7 = Sunday)
โ”‚  โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€ month           (1-12)
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ day             (1-31)
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ hour            (0-23)
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ minute          (0-59)

Special characters โ€‹

CharacterMeaningExample
*every value* * * * * โ€” every minute
,list0,15,30,45 * * * * โ€” once every 15 minutes
-range9-17 โ€” hours 9 through 17
/step*/5 โ€” every 5 minutes
*/Nwith a step*/15 * * * *

Real examples โ€‹

cron
# Every minute
* * * * * /opt/heartbeat.sh

# Every 5 minutes
*/5 * * * * /opt/healthcheck.sh

# At minute 0 of every hour
0 * * * * /opt/log-rotate.sh

# Every day at 02:00
0 2 * * * /opt/backup.sh

# Every Sunday night at 03:30
30 3 * * 0 /opt/weekly-report.sh

# Every hour during business hours 9-17 (1-5 = Monday-Friday)
0 9-17 * * 1-5 /opt/business-check.sh

# On the 1st of every month at 00:30
30 0 1 * * /opt/monthly-billing.sh

# Every 3 hours
0 */3 * * * /opt/cache-refresh.sh

# Every 30 minutes, on weekdays only
*/30 * * * 1-5 /opt/sync.sh

Special strings โ€” @ โ€‹

StringEquivalentMeaning
@reboot(on boot)Once when the system boots
@yearly0 0 1 1 *Once a year
@monthly0 0 1 * *Once a month
@weekly0 0 * * 0Once a week (Sunday)
@daily0 0 * * *Once a day
@hourly0 * * * *Once an hour
cron
@reboot   /opt/startup.sh
@daily    /opt/backup.sh
@hourly   /opt/cleanup.sh

Crontab generator

For complex syntax โ€” crontab.guru โ€” interactive online analysis. A reliable tool.


4.4. Cron environment and the PATH problem โ€‹

The most common cron error: "The script works in the terminal but not in cron."

The reason โ€” in the cron environment the PATH is minimal:

cron
# Looks simple, but wrong:
* * * * * mybackup.sh
# cron: `mybackup` not found (not in PATH)

Cron PATH default โ€‹

On most systems:

PATH=/usr/bin:/bin

Missing: /usr/local/bin, ~/bin, ~/.cargo/bin, ~/.local/bin, etc.

Solution โ€” 3 ways โ€‹

1. Use the full path (the most precise):

cron
* * * * * /usr/local/bin/mybackup.sh

2. Set PATH in the cron file:

cron
PATH=/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash

* * * * * mybackup.sh

3. Load PATH at the start of the script:

bash
#!/usr/bin/env bash
source ~/.bashrc   # or explicitly:
export PATH="/usr/local/bin:/usr/bin:/bin"

# ... work ...

Login shell vs Cron shell

Cron is a non-interactive shell. If ~/.bashrc has [[ $- == *i* ]] && return, the settings won't be loaded.

To test:

bash
* * * * * env > /tmp/cron-env.txt

Wait one minute, then run cat /tmp/cron-env.txt โ€” you'll see the cron environment.


4.5. Logging and debugging โ€‹

By default, cron output is sent to the user via mail. If mail isn't configured โ€” it's lost.

The good way โ€” always redirect to a file โ€‹

cron
# Both stdout and stderr to one file
0 2 * * * /opt/backup.sh >> /var/log/backup.log 2>&1

# stdout and stderr separately
0 2 * * * /opt/backup.sh > /var/log/backup.out 2>> /var/log/backup.err

# Discard everything (if you don't want it at all)
0 2 * * * /opt/silent.sh > /dev/null 2>&1

Disabling MAILTO

To get rid of the mail nuisance, at the top of the crontab:

cron
MAILTO=""

0 2 * * * /opt/backup.sh >> /var/log/backup.log 2>&1

Or to a specific email:

cron
MAILTO="admin@example.com"

System cron logs โ€‹

bash
# Ubuntu / Debian
tail -f /var/log/syslog | grep CRON

# RHEL / CentOS
tail -f /var/log/cron

# macOS
log show --predicate 'process == "cron"' --last 1h

Script debug mode โ€‹

bash
#!/usr/bin/env bash
set -euo pipefail

# When running in cron, write to the log
exec >> /var/log/myscript.log 2>&1
echo "===== $(date) =====" 

# ... main work ...

exec >> file 2>&1 โ€” redirects all subsequent output of the script to the file.


4.6. at โ€” a one-time delayed task โ€‹

cron is recurring. at runs once at a future time.

bash
# Today at 18:00
echo "/opt/notify.sh" | at 18:00

# Tomorrow
echo "/opt/task.sh" | at 09:00 tomorrow

# In 1 hour
echo "/opt/reminder.sh" | at now + 1 hour

# A specific date
echo "/opt/event.sh" | at 14:30 2026-06-01

# Interactive (multiple lines)
at 22:00
> notify-send "Time's up"
> /opt/cleanup.sh
> Ctrl+D

Management โ€‹

bash
atq           # pending tasks
# 5    Sat May 17 18:00:00 2026 a ali

atrm 5        # cancel

at -c 5       # view the commands inside it

Cron vs at โ€” when to use which

  • Cron โ€” recurring: backup, monitoring, cleanup
  • at โ€” one-time: "reboot the server tomorrow morning"

4.7. systemd timers โ€” the modern alternative โ€‹

Modern Linux distributions have systemd โ€” with many advantages over cron.

Structure โ€” .service + .timer โ€‹

/etc/systemd/system/backup.service:

ini
[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/opt/backup.sh
StandardOutput=journal
StandardError=journal

/etc/systemd/system/backup.timer:

ini
[Unit]
Description=Nightly backup timer
Requires=backup.service

[Timer]
OnCalendar=daily        # = 00:00
OnCalendar=*-*-* 02:00:00  # exactly 02:00
Persistent=true         # catch up missed runs
RandomizedDelaySec=30m  # random delay of 0-30 minutes

[Install]
WantedBy=timers.target

Enabling and managing โ€‹

bash
# Reload (after adding a new file)
sudo systemctl daemon-reload

# Enable and start
sudo systemctl enable --now backup.timer

# Status
systemctl status backup.timer
systemctl list-timers --all

# Logs
journalctl -u backup.service       # service log
journalctl -u backup.service --since "1 hour ago"

# Manual run (test)
sudo systemctl start backup.service

OnCalendar= syntax โ€‹

ini
OnCalendar=hourly                       # every hour
OnCalendar=daily                        # every day at 00:00
OnCalendar=weekly                       # every Sunday
OnCalendar=monthly                      # on the 1st of every month
OnCalendar=Mon..Fri 09:00               # weekdays at 9:00
OnCalendar=*-*-* 02:00:00               # every day at 02:00
OnCalendar=*:0/15                       # every 15 minutes
OnCalendar=Sat,Sun 04:00                # on weekends

systemd-analyze calendar

bash
systemd-analyze calendar "Mon..Fri 09:00"
# Original form: Mon..Fri 09:00
# Normalized form: Mon..Fri *-*-* 09:00:00
# Next elapse: Mon 2026-05-19 09:00:00 +05

Check that your expression is correct first.


4.8. cron vs systemd timers โ€” a comparison โ€‹

Featurecronsystemd timer
Age19752010
Universal Unixโœ…โŒ (only Linux with systemd)
macOS / *BSDโœ…โŒ
Logging integrationmanualโœ… journalctl
Catching up missed runsโŒโœ… Persistent=true
Sandboxing (security)โŒโœ… full
Resource limits (MemoryMax)โŒโœ…
Dependencies (on other services)โŒโœ… Requires=
Simple casesโœ…overkill
Complex workflowshardโœ… natural

Rule

  • A single server, a simple task โ†’ cron
  • Production services โ†’ systemd timers
  • macOS โ†’ cron (or launchd)

macOS โ€” launchd

On macOS, cron is officially deprecated (but still works). The recommended tool is launchd (.plist files in ~/Library/LaunchAgents/).

For simple cases, you can use cron. But on macOS cron sometimes won't work without Full Disk Access permission โ€” you may need to grant it via System Preferences.


4.9. flock โ€” a single-instance guarantee โ€‹

The most classic cron problem: a script is supposed to run in 1 minute, but it's taking 2 minutes. On the next tick it starts again โ€” two instances in parallel. A race over the disk or database.

Solution: a lock file with flock.

cron
* * * * * flock -n /tmp/myjob.lock /opt/myjob.sh

flock -n โ€” if it can't acquire the lock, it exits immediately (with an error). The second instance won't start.

Inside the script โ€‹

bash
#!/usr/bin/env bash
exec 200>/var/run/myjob.lock
flock -n 200 || { echo "Already running"; exit 1; }

# ... main work ...

flock flags โ€‹

FlagMeaning
-nExit immediately if the lock can't be acquired
-w <sec>Wait N seconds, then return an error
-xExclusive lock (default)
-sShared lock
-uUnlock (manually)
cron
# Wait 30s, then if it errors, write it to the log
* * * * * flock -w 30 /tmp/myjob.lock /opt/myjob.sh >> /var/log/myjob.log 2>&1

4.10. A real example โ€” a production-grade nightly backup โ€‹

bash
#!/usr/bin/env bash
#
# nightly-backup.sh โ€” production backup with notifications
#
# crontab entry:
#   0 2 * * * flock -n /tmp/backup.lock /opt/bin/nightly-backup.sh
#

set -euo pipefail
IFS=$'\n\t'

# === Configuration ===
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/backup.log"
readonly SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
readonly HEALTHCHECK_URL="${HEALTHCHECK_URL:-}"

readonly BACKUP_DIR="/var/backups"
readonly SOURCE_DIRS=("/var/www" "/etc")
readonly KEEP_DAYS=14

# === Logging ===
exec >> "$LOG_FILE" 2>&1
echo ""
echo "===== $(date '+%Y-%m-%d %H:%M:%S') ====="

log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; }

# === Notification ===
notify_slack() {
    [[ -z "$SLACK_WEBHOOK" ]] && return 0
    local msg="$1"
    curl -fsS -X POST -H 'Content-Type: application/json' \
        -d "{\"text\":\"$msg\"}" \
        "$SLACK_WEBHOOK" > /dev/null
}

# === Cleanup and error handling ===
cleanup() {
    local rc=$?
    if [[ $rc -ne 0 ]]; then
        log "โŒ Backup ended with an error (exit=$rc)"
        notify_slack ":x: Backup failed on $(hostname) (exit=$rc, see $LOG_FILE)"
    fi
}
trap cleanup EXIT

# === Main work ===
log "๐Ÿ“ฆ Backup started"
mkdir -p "$BACKUP_DIR"

archive="$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).tar.gz"
log "๐Ÿ“ Archive: $archive"

if ! tar -czf "$archive" "${SOURCE_DIRS[@]}" 2>&1; then
    log "โŒ tar failed"
    exit 1
fi

size=$(du -sh "$archive" | cut -f1)
log "โœ… Archive ready: $size"

# === Cleaning up old backups ===
log "๐Ÿงน Deleting backups older than ${KEEP_DAYS} days..."
find "$BACKUP_DIR" -name "backup_*.tar.gz" -mtime "+$KEEP_DAYS" -delete

# === Healthcheck ping ===
if [[ -n "$HEALTHCHECK_URL" ]]; then
    curl -fsS --max-time 10 "$HEALTHCHECK_URL" > /dev/null \
        && log "๐Ÿ’š Healthcheck ping sent"
fi

# === Success notification ===
notify_slack ":white_check_mark: Backup OK on $(hostname) โ€” size: $size"
log "๐ŸŽ‰ Finished"

Crontab entry โ€‹

cron
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
SLACK_WEBHOOK=https://hooks.slack.com/services/...
HEALTHCHECK_URL=https://hc-ping.com/uuid-here

# Every day at 02:00 โ€” with a lock, single instance
0 2 * * * flock -n /tmp/backup.lock /opt/bin/nightly-backup.sh

What does this script do? โ€‹

FeatureWhere
Single instance with a lockflock -n in crontab
A central log fileexec >> "$LOG_FILE" 2>&1
Slack notification (success+fail)notify_slack
Healthcheck.io pingcurl HEALTHCHECK_URL
Auto-cleanup of old backupsfind ... -mtime +N -delete
Error handling via traptrap cleanup EXIT
Production-grade error reportingexit code โ†’ Slack alert

4.11. Common mistakes โ€‹

Classic traps

  1. The PATH problem. The script works in the terminal but not in cron. Solution: the full path, or PATH=... at the top of the crontab.

  2. Output is lost. By default, cron output goes to mail. Solution: >> file 2>&1 always.

  3. MAILTO not configured, mail spam every minute.MAILTO="" at the top of the crontab.

  4. ~ doesn't work in cron.~ is for an interactive shell. In cron, use $HOME or the full path.

  5. Confusing crontab -r.-r = remove (everything). -e = edit. If you delete by accident โ€” you've lost it.

  6. Time zone โ€” UTC vs local. The server is usually in UTC. On a new server, check with timedatectl status.

  7. The % character is special in cron. In a cron line, % means a newline. If you need a literal character, escape it: \%. Better โ€” move date +"%F" into a separate script.

  8. No single-instance guarantee. If you don't use flock โ€” a long-running job can run twice in parallel.

  9. macOS cron Full Disk Access. System Preferences โ†’ Security & Privacy โ†’ Full Disk Access โ€” add cron.

  10. Not understanding how a faulty cron file update behaves. When you save via crontab -e โ€” it takes effect immediately. But no SIGTERM is sent: a running task keeps using the old syntax.


4.12. Exercises โ€‹

๐Ÿงช Bashlings โ€” interactive exercises

This chapter's 7 exercises come with auto-checking via the bashlings CLI. None require a cron daemon โ€” they work with syntax and parsing:

bash
bashlings watch              # start from the first pending exercise
bashlings run cron1          # check a single exercise
bashlings hint cron1         # step-by-step hint

Source: exercises/14_cron/

Also try the following additional conceptual exercises:

  1. Cron syntax parser โ€” a quiz on explaining the following cron lines as "minute 30 of every hour" or "every 30 minutes during business hours 9-17":

    • 30 * * * *
    • */30 9-17 * * 1-5
    • 0 0 1 * *
  2. Logging wrap โ€” convert the following cron line into the correct form with logging + MAILTO:

    cron
    * * * * * mybackup.sh
  3. flock lock โ€” add flock -n to the cron line for the myjob.sh script, with the lock file being /tmp/myjob.lock.

  4. at reminder โ€” create an at job that prints "time's up" after 30 minutes and check it with atq.

  5. Systemd timer โ€” write a myping.service and myping.timer file that pings every 15 minutes.


4.13. Summary โ€‹

ConceptKey point
crontab -eEdit
crontab -lCurrent list
crontab -rโš  Delete EVERYTHING
5 fieldsminute hour day month day-of-week
*/5Every 5 (step)
@daily, @hourlySpecial strings
0 2 * * *Every day at 02:00
>> log 2>&1Output always to a file
MAILTO=""Turn off mail spam
PATH=...Mandatory to set in cron
flock -n /tmp/x.lockSingle-instance guarantee
at 18:00A one-time delay
OnCalendar=dailyThe systemd timer equivalent
journalctl -u <service>systemd logs

5 key ideas โ€‹

  1. PATH is minimal in cron โ€” use the full path or PATH=... at the top of the crontab.
  2. >> file 2>&1 always โ€” otherwise output is lost.
  3. flock -n โ€” on every production cron line.
  4. crontab.guru โ€” check complex syntax online.
  5. systemd timer โ€” preferred for production on Linux (logging + sandboxing + missed-run catch-up).

๐ŸŽ‰ Now you can automate tasks. In the next chapter โ€” working with containers via Docker.

Next page: 5. Integration with Docker โ†’

Released under the MIT License.