cron and crontab
cron schedules commands with a five-field expression (minute hour dom month dow). User crontabs live under /var/spool/cron; system jobs in /etc/cron.d. The cron environment is minimal — bare PATH, no profile — which is the top source of "works in my shell, fails in cron".
Every system has work that must repeat: nightly backups, hourly log rotations, a health-check ping every five minutes. cron has been the answer since 1975. You write a line like 0 3 * * * /usr/bin/backup.sh and the daemon wakes up every minute, checks whether any job is due, and runs it. Simple. But cron has one nasty habit: it runs jobs in a stripped-down environment — no profile, no bashrc, a bare PATH that knows nothing about /usr/local/bin or your language runtimes. A script that works perfectly in your shell will silently fail in cron for exactly that reason. Understanding the five time fields is five minutes of learning; understanding the environment trap saves you hours of debugging.
After this lesson you can write a crontab entry with all five time fields, edit your user crontab with crontab -e, add a system job in /etc/cron.d, use @reboot and @daily shortcuts, and avoid the minimal-PATH trap that breaks most first-time cron jobs.
The five time fields control when the job runs. A crontab line has five time fields followed by the command:
# m h dom mon dow command
# | | | | |
# | | | | +--- day of week (0=Sun, 6=Sat; 7=Sun too)
# | | | +-------- month (1-12)
# | | +------------- day of month (1-31)
# | +----------------- hour (0-23)
# +-------------------- minute (0-59)
# Run at 03:00 every night:
0 3 * * * /usr/bin/backup.sh
# Run at minute 0 and 30 every hour (two values, comma-separated):
0,30 * * * * /usr/bin/check-health.sh
# Run every 15 minutes (step syntax with /):
*/15 * * * * /usr/bin/poll-queue.sh
# Run at 08:00 on weekdays only (Mon-Fri):
0 8 * * 1-5 /usr/bin/morning-report.shAn asterisk * means “every”. A comma separates values. A slash /N means “every N”. A dash - creates a range. These four constructs cover almost every schedule you will ever need.
Edit your personal crontab with crontab -e. Each user has their own crontab. The daemon runs jobs as that user, with that user’s permissions — no password required, no sudo needed for user-owned tasks.
# Open your crontab in $EDITOR (vi by default):
crontab -e
# List the current crontab without editing:
crontab -l
# Remove your entire crontab (careful — no undo):
crontab -r
# Edit another user's crontab (root only):
crontab -u alice -eThe file is stored under /var/spool/cron/crontabs/<username> — never edit it directly; always go through crontab -e, which validates syntax before installing.
System-wide jobs live in /etc/cron.d/ and /etc/crontab. These files add a sixth field — the username — because the daemon needs to know which user to run the job as.
# /etc/crontab and /etc/cron.d files have a USER field between the time fields and command:
# m h dom mon dow USER command
0 2 * * * root /usr/sbin/logrotate /etc/logrotate.conf
# In /etc/cron.d/ you can drop separate files per package:
ls /etc/cron.d/
# php apt-compat sysstat unattended-upgrades
# Check what Ubuntu's own daily/weekly/monthly jobs look like:
ls /etc/cron.daily/ # scripts, executed by run-parts
ls /etc/cron.weekly/
ls /etc/cron.monthly/The /etc/cron.daily/ directories are run by run-parts — any executable script in there fires at the scheduled time. No crontab syntax required; just drop a script and make it executable.
@reboot, @daily, and friends replace the five fields for common schedules. These are shorthand strings that cron translates internally:
# Equivalent shorthands:
@reboot /usr/bin/start-agent.sh # Run once, when the daemon starts
@hourly /usr/bin/check-disk.sh # 0 * * * *
@daily /usr/bin/nightly-backup.sh # 0 0 * * *
@weekly /usr/bin/weekly-cleanup.sh # 0 0 * * 0
@monthly /usr/bin/monthly-report.sh # 0 0 1 * *
@yearly /usr/bin/audit.sh # 0 0 1 1 *
# @reboot is the most useful shorthand: replaces /etc/rc.local for user-level
# startup commands without needing a systemd unit.@reboot fires when crond starts, not strictly when the machine boots — but on Ubuntu with crond enabled, crond starts at boot, so they are functionally equivalent for most uses.
The cron environment is minimal — this is the #1 failure mode. When cron runs your command, it sets a bare environment: SHELL=/bin/sh, PATH=/usr/bin:/bin, HOME and LOGNAME from the password file. That is it. No .bashrc, no .profile, no nvm, no pyenv, no /usr/local/bin.
# A job that fails silently because 'node' is not in /usr/bin:/bin:
*/5 * * * * node /opt/app/worker.js
# Fix 1: use the absolute path to the binary:
*/5 * * * * /usr/local/bin/node /opt/app/worker.js
# Fix 2: override PATH at the top of the crontab (applies to all jobs below):
PATH=/usr/local/bin:/usr/bin:/bin
*/5 * * * * node /opt/app/worker.js
# Fix 3: source the profile explicitly in the command:
*/5 * * * * bash -l -c 'node /opt/app/worker.js'
# Capture stdout AND stderr to a log file for debugging:
*/5 * * * * /usr/local/bin/node /opt/app/worker.js >> /var/log/worker.log 2>&1The >> /var/log/worker.log 2>&1 tail is the single most useful addition to any cron job: without it, output is mailed to the user (if a mail agent is running) or silently discarded.
Add a daily database dump that actually works in cron.
Goal: dump a PostgreSQL database every day at 02:30, write to /var/backups/db/, keep 7 days of files.
# Edit the postgres user's crontab:
sudo crontab -u postgres -e
# Add these lines (the top two set the environment; the job line follows):
PATH=/usr/bin:/bin
PGPASSFILE=/var/lib/postgresql/.pgpass
30 2 * * * pg_dump mydb | gzip > /var/backups/db/mydb-$(date +\%Y\%m\%d).sql.gz
35 2 * * * find /var/backups/db/ -name 'mydb-*.sql.gz' -mtime +7 -deleteThree things to note:
PATHis set explicitly —pg_dumpis in/usr/binon Debian, so this would work without it here, but it is a habit worth building.date +\%Y\%m\%duses\%because%is a special character in crontab syntax — it is translated to a newline. Any literal%in a crontab command must be escaped as\%.- The cleanup job at 02:35 uses
find -mtime +7— files older than 7 days. Running it 5 minutes after the dump ensures the fresh dump is not deleted.
▸Common mistake
The % escape trap. In crontab syntax, an unescaped % is translated into a newline, and everything after the first % becomes stdin for the command. This means date +%Y%m%d in a crontab line becomes date + followed by a newline and then Y etc. as stdin — the date command sees only the format string up to the first % and produces wrong output or an error. Always write \% in crontab: date +\%Y\%m\%d. This trap hits every engineer once, usually in a backup script where a corrupted filename is the only symptom.
▸Why this works
macOS and cron. On macOS, cron is present but officially deprecated since macOS 10.4 (Tiger). Apple’s replacement is launchd and launchctl with .plist files in ~/Library/LaunchAgents/ for user jobs. The syntax is XML-based and verbose, but it integrates with macOS power management (jobs can wake the machine). For cross-platform shell scripts that also run on macOS servers, consider whether the job truly needs macOS-native scheduling or whether you can deploy it on a Linux host instead.
You write this crontab entry: `*/10 * * * * python3 /opt/app/sync.py`. The script runs fine from your shell but never produces output in cron. What is the most likely cause?
A crontab entry has five time fields — minute, hour, day-of-month, month, day-of-week — followed by the command. Use * for “every”, , for multiple values, /N for step, - for range. Edit your personal crontab with crontab -e; system jobs with a username field go in /etc/cron.d/. @reboot and @daily are readable shortcuts for common schedules. The most important rule: cron runs in a minimal environment — PATH=/usr/bin:/bin, no profile — so always use absolute paths to binaries or set PATH at the top of the crontab. Redirect output with >> logfile 2>&1 to make failures visible. Escape literal % as \% in cron commands.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.