Skip to main content
Bash intermediate Lesson 18 of 22

Cron and Scheduling in Bash

Master crontab syntax, cron expressions, at for one-time jobs, systemd timers, and logging cron output properly.

Crontab Basics

Cron is the standard Unix job scheduler. It runs commands at specified times — backups at 2 AM, health checks every 5 minutes, reports every Monday morning. Understanding cron is essential for any server-side automation work. Every DevOps engineer needs to be comfortable reading and writing crontab entries.

crontab -e    # open your crontab in an editor
crontab -l    # list your current crontab
crontab -r    # remove your entire crontab (careful — no confirmation!)

The format of a crontab entry:

┌──────── 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)
│ │ │ │ │
* * * * *  /path/to/command

Common Cron Expressions

Learning to read and write cron expressions fluently saves significant time. These are the patterns you will encounter most often in production crontabs.

# Every minute
* * * * * /usr/local/bin/check-status.sh

# Every 5 minutes (*/5 means "every 5th")
*/5 * * * * /usr/local/bin/poll.sh

# Every hour at minute 0
0 * * * * /usr/local/bin/hourly-cleanup.sh

# Every day at 2:30 AM
30 2 * * * /usr/local/bin/backup.sh

# Every Monday at 9:00 AM
0 9 * * 1 /usr/local/bin/weekly-report.sh

# Every weekday (Mon–Fri) at 8:00 AM
0 8 * * 1-5 /usr/local/bin/workday-start.sh

# First day of every month at midnight
0 0 1 * * /usr/local/bin/monthly-rollup.sh

# Every 15 minutes between 9 AM and 5 PM on weekdays
*/15 9-17 * * 1-5 /usr/local/bin/monitor.sh

# Multiple specific hours (8 AM, 12 PM, 6 PM)
0 8,12,18 * * * /usr/local/bin/report.sh

# Run once at system reboot (before cron starts processing time-based entries)
@reboot /usr/local/bin/startup-tasks.sh

# Shortcut aliases for common schedules
@hourly   /usr/local/bin/hourly.sh     # equivalent to: 0 * * * *
@daily    /usr/local/bin/daily.sh      # equivalent to: 0 0 * * *
@weekly   /usr/local/bin/weekly.sh     # equivalent to: 0 0 * * 0
@monthly  /usr/local/bin/monthly.sh    # equivalent to: 0 0 1 * *
@yearly   /usr/local/bin/yearly.sh     # equivalent to: 0 0 1 1 *

Crontab Best Practices

The most common reason cron jobs silently fail is the minimal environment cron runs in. It has no PATH beyond /usr/bin:/bin, no loaded profile, and no aliases. Every cron job should set its environment explicitly and redirect its output to a log file.

# /etc/cron.d/myapp  or crontab -e

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)

# Redirect stdout and stderr to a log file — without this, output is silently lost
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

# Suppress all output if the script handles its own logging internally
*/5 * * * * /usr/local/bin/monitor.sh &>/dev/null

A cron job script that handles its own logging correctly:

#!/usr/bin/env bash
# /usr/local/bin/backup.sh
set -euo pipefail

LOG="/var/log/myapp/backup.log"
mkdir -p "$(dirname "$LOG")"

# Redirect all further output (stdout and stderr) to the log file
exec >> "$LOG" 2>&1

echo "=== Backup started: $(date) ==="
# ... backup logic ...
echo "=== Backup finished: $(date) ==="

Preventing Overlapping Cron Jobs

If a cron job takes longer than its schedule interval, a second instance starts while the first is still running. This can cause data corruption, double-processing, or race conditions. flock prevents this atomically — the second instance either waits or exits immediately.

# In the crontab — simplest approach
*/5 * * * * flock -n /tmp/monitor.lock /usr/local/bin/monitor.sh

# Inside the script — more portable, works regardless of how the script is called
#!/usr/bin/env bash
LOCKFILE="/tmp/$(basename "$0").lock"

# Open FD 9 pointing at the lockfile, then try to acquire an exclusive lock
exec 9>"$LOCKFILE"
if ! flock -n 9; then
  echo "Another instance is running. Exiting." >&2
  exit 0
fi
# Lock is held — the lock is released automatically when the script exits
echo "Running..."

System-Wide Cron Directories

On Debian/Ubuntu and RHEL-based systems, you can deploy scripts into cron directories instead of editing crontabs. This is the right approach for system-level jobs installed by packages or configuration management tools.

# Drop scripts into these directories for automatic scheduling
/etc/cron.hourly/      # runs every hour
/etc/cron.daily/       # runs daily (typically at 6:25 AM)
/etc/cron.weekly/      # runs weekly (typically Sunday)
/etc/cron.monthly/     # runs monthly

# Requirements for scripts in these directories:
# - must be executable (chmod +x)
# - must not have a file extension (.sh extension breaks run-parts on some systems)
# - must exit 0 on success

# Deploy a script (no extension, executable)
cp backup.sh /etc/cron.daily/myapp-backup
chmod +x /etc/cron.daily/myapp-backup

at — One-Time Scheduled Jobs

at runs a command once at a specific future time. It is useful for scheduling maintenance tasks, delayed restarts, or one-off operations without creating a permanent cron entry.

# Schedule a command interactively
at 3:00 AM tomorrow
at> /usr/local/bin/maintenance.sh
at> <Ctrl+D>

# Schedule from a script (non-interactively)
echo "/usr/local/bin/restart.sh" | at now + 10 minutes
echo "/usr/local/bin/report.sh"  | at 9:00 AM
echo "/usr/local/bin/cleanup.sh" | at midnight

# List scheduled at jobs
atq

# Remove a scheduled job by its job number
atrm 3

systemd Timers

systemd timers are the modern alternative to cron on Linux systems running systemd. They offer better observability (all output goes to journald), missed-run catch-up (if the machine was off when a job was scheduled), monotonic timers, and dependency management.

Create a service unit (/etc/systemd/system/myapp-backup.service):

[Unit]
Description=MyApp Backup
After=network.target

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh
StandardOutput=journal
StandardError=journal

Create a timer unit (/etc/systemd/system/myapp-backup.timer):

[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

Enable and manage the timer:

systemctl daemon-reload
systemctl enable --now myapp-backup.timer

# Check timer status and next scheduled run time
systemctl status myapp-backup.timer
systemctl list-timers

# View job output in the journal
journalctl -u myapp-backup.service
journalctl -u myapp-backup.service -f   # follow in real time

Common OnCalendar expressions:

OnCalendar=hourly               # every hour
OnCalendar=daily                # daily at midnight
OnCalendar=weekly               # weekly (Monday midnight)
OnCalendar=*-*-* 02:00:00       # daily at 2 AM
OnCalendar=Mon *-*-* 09:00:00   # every Monday at 9 AM
OnCalendar=*-*-1 00:00:00       # first day of every month
OnCalendar=*:0/5                # every 5 minutes

Logging Cron Output

Without explicit output redirection, cron attempts to email output to the local user — which silently disappears on most servers. Always capture output explicitly. Three patterns cover most cases:

# Pattern 1: redirect in the crontab line itself (simplest)
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

# Pattern 2: prepend a timestamp to every output line for correlation
0 2 * * * /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.log

# Pattern 3: send to syslog/journald via logger (integrates with centralized logging)
0 2 * * * /usr/local/bin/backup.sh 2>&1 | logger -t backup -p local0.info

Monitoring Cron Jobs

Detecting that a scheduled job has stopped running silently is as important as logging what it does when it runs. A simple sentinel file updated at the end of each run lets external monitoring detect missed executions.

# In your monitoring script: check if a job ran within the expected window
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 daily jobs that missed a run
  echo "ALERT: myjob has not run for $(( age / 3600 )) hours" >&2
fi

# In your cron script: update the sentinel file at the end of each successful run
touch /var/run/myjob.lastrun

What’s Next

The next tutorial covers Bash security — safe temp files, avoiding command injection, handling secrets, and file permissions.

Frequently Asked Questions

Why does my cron job not run even though the crontab looks correct?
Cron runs with a minimal environment — no PATH, no aliases, no sourced .bashrc. Always use absolute paths for commands and scripts. Add PATH=/usr/local/bin:/usr/bin:/bin at the top of your crontab.
How do I prevent multiple instances of a cron job from running at the same time?
Use a lockfile with flock: 0 * * * * flock -n /tmp/myjob.lock /path/to/script.sh. If the lock is held, the new instance exits immediately.
What is the difference between cron and systemd timers?
systemd timers are more powerful — they support monotonic timers (run X minutes after boot), missed-run catch-up, better logging via journald, and dependencies on other units. crontab is simpler and universally available.