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.