cheat sheet
cron
Time-based job scheduler on Unix. Covers crontab syntax, user vs. system crontabs, environment quirks, logging, anacron, and the modern systemd timer alternative.
cron — Scheduled Jobs
What it is
cron is the venerable Unix time-based job scheduler, present on every Linux system. A persistent daemon (crond or cron) reads per-user and system crontab files, parses each line's schedule expression, and runs the command on the schedule's tick. The implementation everyone uses on modern Linux is Vixie cron (or its security-hardened fork cronie); BSD systems use the original BSD cron. Reach for cron when you want a small, file-driven scheduler with zero configuration; for richer features (dependencies, retries, structured logging, calendar dates), use systemd timers instead.
Install
# Debian/Ubuntu — usually pre-installed; cron service is running by default
sudo apt install cron
sudo systemctl enable --now cron
# Fedora / RHEL (uses cronie)
sudo dnf install cronie
sudo systemctl enable --now crond
# macOS — cron is installed but launchd is the recommended scheduler
# Use `launchctl` and ~/Library/LaunchAgents for native macOS scheduling.
Output: (none — exits 0 on success)
Configuration
Cron is configured by a handful of well-known paths. The daemon reads system files at startup and re-reads user crontabs each time crontab mutates them, so you rarely need to restart crond/cron after edits.
| Path | Purpose |
|---|---|
/etc/crontab | Main system crontab; has an extra user field. Edited as root with a text editor. |
/etc/cron.d/* | Drop-in directory — one file per package or job. Same format as /etc/crontab. Preferred over editing /etc/crontab directly because package upgrades leave drop-ins alone. |
/etc/cron.hourly/ | All executables here run every hour (via run-parts). |
/etc/cron.daily/ | All executables here run once a day. |
/etc/cron.weekly/ | All executables here run once a week. |
/etc/cron.monthly/ | All executables here run once a month. |
/var/spool/cron/ (Debian: /var/spool/cron/crontabs/) | Per-user crontabs. Owned by root; written indirectly via crontab -e. Do not edit by hand. |
/etc/cron.allow | Allowlist of users who may use crontab. If present, only these users may schedule jobs. |
/etc/cron.deny | Denylist of users who may not use crontab. Consulted only when cron.allow is absent. |
/etc/anacrontab | anacron schedule for catch-up runs on machines that aren't always on. |
/var/log/cron or syslog/journald | Cron's own activity log — what fired, what failed, MTA warnings. |
Files in /etc/cron.{hourly,daily,weekly,monthly}/ are executed by run-parts, which ignores files whose names contain a dot or characters other than [A-Za-z0-9_-]. A script named backup.sh will not run; rename it to backup and make it executable.
Access control defaults differ across distros. Debian/Ubuntu ships with neither
cron.allownorcron.deny, so any user may schedule jobs. RHEL/Fedora ships an emptycron.deny. To lock crontab access to a single user, create/etc/cron.allowcontaining just that username — the mere existence of the file makes it authoritative.
Crontab syntax
A crontab line is five whitespace-separated time fields followed by the command. The fields are minute, hour, day-of-month, month, and day-of-week. Each field is either * (every value), a number, a comma-separated list, a range, a step, or any combination. The command runs whenever the wall-clock time matches the schedule.
┌──── minute (0–59)
│ ┌── hour (0–23)
│ │ ┌─ day-of-month (1–31)
│ │ │ ┌── month (1–12 or jan–dec)
│ │ │ │ ┌── day-of-week (0–6 or sun–sat; 0 and 7 are Sunday)
│ │ │ │ │
* * * * * COMMAND
Output: (none — exits 0 on success)
Field operators
| Syntax | Means |
|---|---|
* | every value |
5 | exact value |
1,15,30 | list of values |
1-5 | range (inclusive) |
*/15 | step (every 15 from the field's minimum) |
0-30/5 | step within a range |
mon-fri | range of named values (Vixie/cronie) |
Common schedules
| Crontab | Runs |
|---|---|
* * * * * | every minute |
*/5 * * * * | every 5 minutes |
0 * * * * | hourly, on the hour |
0 3 * * * | every day at 03:00 |
0 3 * * mon-fri | weekdays at 03:00 |
30 4 1 * * | the 1st of every month at 04:30 |
0 0 1 1 * | once a year (midnight Jan 1) |
0 9-17 * * 1-5 | hourly during business hours, weekdays |
15,45 * * * * | at :15 and :45 every hour |
# Sample crontab entries
*/5 * * * * /home/alice/bin/poll.sh
0 3 * * * /home/alice/bin/backup.sh
30 4 1 * * /usr/local/bin/rotate-archives
Output: (none — exits 0 on success)
Use crontab.guru to sanity-check a schedule expression in plain English before you save the file. It catches the classic "I meant every five minutes but wrote
5 * * * *" mistake — that one fires only once per hour, at :05.
Named (extended) schedules
Vixie cron and cronie accept a handful of @-prefixed shortcuts in place of the five time fields. They're more readable, and @reboot doesn't have a time-field equivalent at all — it fires once when cron itself starts.
| Shortcut | Equivalent |
|---|---|
@yearly / @annually | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily / @midnight | 0 0 * * * |
@hourly | 0 * * * * |
@reboot | once at cron daemon startup |
@daily /home/alice/bin/backup.sh
@reboot /home/alice/bin/start-tunnel.sh
Output: (none — exits 0 on success)
User crontabs
Each user has their own crontab, edited with crontab -e. The file is stored under /var/spool/cron/ (location varies by distro) and is owned by root but writable indirectly via the crontab command. Lines you add run as that user; you don't need root for normal scheduling.
crontab -e # edit your crontab in $EDITOR
crontab -l # list your crontab
crontab -r # remove your crontab (no confirmation!)
crontab -i -r # same, but prompt before deleting
# Replace your crontab atomically from a file
crontab my-jobs.txt
Output: (none — exits 0 on success)
A minimal user crontab looks like:
# m h dom mon dow command
*/10 * * * * /home/alice/bin/check-feeds.sh
0 2 * * * /home/alice/bin/nightly.sh >> /home/alice/log/nightly.log 2>&1
[!WARN]
crontab -rdeletes the file with no confirmation and no undo. Always back up first withcrontab -l > /home/alice/crontab.bak, or usecrontab -i -rso it prompts.
System crontabs
The system-wide files live outside /var/spool/ and have an extra user field between the schedule and the command. Edit them as root with a regular text editor — crontab -e doesn't apply.
| Path | Purpose |
|---|---|
/etc/crontab | main system crontab |
/etc/cron.d/ | drop-in directory — one file per package/job |
/etc/cron.hourly/ | run all executables hourly |
/etc/cron.daily/ | run all executables daily |
/etc/cron.weekly/ | run all executables weekly |
/etc/cron.monthly/ | run all executables monthly |
A file in /etc/cron.d/ has the same schedule fields as a user crontab but with an extra user column:
# /etc/cron.d/myapp-cleanup
# m h dom mon dow user command
30 3 * * * alice /home/alice/bin/cleanup.sh
*/5 * * * * root /usr/local/sbin/healthcheck
Output: (none — exits 0 on success)
Prefer dropping a file in
/etc/cron.d/to editing/etc/crontab. Package upgrades sometimes touch/etc/crontab, but they leave/etc/cron.d/files alone.
Environment and PATH
Cron runs jobs in a minimal environment. $PATH is /usr/bin:/bin (or /usr/sbin:/usr/bin:/sbin:/bin for the system crontab), $HOME is the user's home, and almost nothing else is set. $BASH_ENV, ~/.bashrc, and ~/.profile are not sourced because cron does not start a login shell. This trips up almost every cron job at least once.
# Set environment at the top of the crontab — these apply to all jobs below
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=alice@example.com
TZ=America/New_York
0 3 * * * /home/alice/bin/backup.sh
Output: (none — exits 0 on success)
Or set the variables inline per-command:
0 3 * * * PATH=/usr/local/bin:/usr/bin:/bin /home/alice/bin/backup.sh
Output: (none — exits 0 on success)
[!WARN] Always use absolute paths for both the command and any files it references.
python script.pyin a crontab will likely fail withcommand not found;/usr/bin/python3 /home/alice/script.pywill work.
Capturing output and exit codes
Cron sends any stdout or stderr to the user via local mail (read with mail or /var/mail/<user>). On most modern systems there is no local mail transport, so the output is silently lost. Always redirect output yourself.
# Append both stdout and stderr to a log file
0 3 * * * /home/alice/bin/backup.sh >> /home/alice/log/backup.log 2>&1
# Discard stdout, keep stderr (so you only get notified on errors)
0 3 * * * /home/alice/bin/backup.sh >/dev/null
# Discard everything (rarely a good idea)
0 3 * * * /home/alice/bin/backup.sh > /dev/null 2>&1
Output: (none — exits 0 on success)
The job's exit code is only visible if mail delivery works. The standard convention is to make the script itself log its own exit status:
#!/usr/bin/env bash
# /home/alice/bin/backup.sh
set -euo pipefail
LOG=/home/alice/log/backup.log
ts() { date '+%Y-%m-%d %H:%M:%S'; }
{
echo "[$(ts)] backup starting"
rsync -a /home/alice/data /srv/backups/
echo "[$(ts)] backup finished OK"
} >> "$LOG" 2>&1
Output: (none — exits 0 on success)
Mail notifications
The classic cron behaviour is "email me when a job has output". MAILTO=address at the top of the crontab sets the recipient; an empty MAILTO="" disables mail entirely. For mail to actually leave the host you need a working MTA (postfix, msmtp, sendmail); on cloud machines it's usually easier to send notifications from inside the script.
MAILTO=alice@example.com
# This will email if rsync writes anything to stdout/stderr.
0 3 * * * rsync -a /home/alice/data /srv/backups/ 2>&1
Output: (none — exits 0 on success)
A modern alternative is dead-man-switch services like cronitor, healthchecks.io, or betteruptime — your script pings a URL when it succeeds, and the service alerts you if the ping doesn't arrive on schedule.
#!/usr/bin/env bash
URL="https://hc-ping.com/<your-uuid>"
if /home/alice/bin/backup.sh; then
curl -fsS --retry 3 "$URL"
else
curl -fsS --retry 3 "$URL/fail"
fi
Output: (none — exits 0 on success)
Locking — preventing overlap
If a job runs longer than its interval, the next tick can start a second copy before the first finishes. Use flock(1) to guarantee a single concurrent instance.
# Acquire an exclusive lock on /var/lock/backup.lock; fail (exit 1)
# immediately if another instance is already running.
*/5 * * * * flock -n /var/lock/backup.lock /home/alice/bin/backup.sh
Output: (none — exits 0 on success)
# Wait up to 30 seconds for the lock, then give up
*/5 * * * * flock -w 30 /var/lock/backup.lock /home/alice/bin/backup.sh
Output: (none — exits 0 on success)
flock -nis your friend for "run this poller every minute but never overlap". Combined with structured logging, it makes for very resilient cron jobs.
anacron — for laptops and machines that aren't always on
Cron assumes the host is up at the scheduled moment; if it's not, the job is simply skipped. anacron solves this by tracking which periodic jobs (daily, weekly, monthly) have run and catching up missed runs the next time the system boots. Most desktops and laptops use it in addition to cron.
The configuration file is /etc/anacrontab:
# /etc/anacrontab
# period(days) delay(minutes) job-identifier command
1 5 cron.daily run-parts /etc/cron.daily
7 25 cron.weekly run-parts /etc/cron.weekly
@monthly 45 cron.monthly run-parts /etc/cron.monthly
Output: (none — exits 0 on success)
anacron is invoked at boot (by a systemd unit or /etc/init.d/anacron) and runs anything overdue. The delay minutes are added to avoid a thundering herd at startup.
systemd timers — the modern alternative
On systemd-based distributions, timer units are a more capable scheduler than cron and are now the recommended default for new production services on RHEL, Debian, Ubuntu, Fedora, and Arch. They support calendar expressions, dependencies on other units, automatic logging via journalctl, randomized delays, resource limits (CPU/memory/IO), and Persistent=true (catch up missed runs, like anacron). Reach for them whenever a job needs structured logging, dependency ordering, missed-run recovery, or per-job resource caps; stay on cron when you want a portable five-field schedule and nothing else.
A minimal timer + service pair:
# ~/.config/systemd/user/backup.service
[Unit]
Description=Nightly backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/home/alice/bin/backup.sh
# Per-job resource caps — no cron equivalent
MemoryMax=1G
CPUQuota=50%
# ~/.config/systemd/user/backup.timer
[Unit]
Description=Run backup nightly at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true # catch up if the host was off at 03:00
RandomizedDelaySec=5min # avoid thundering herd across hosts
[Install]
WantedBy=timers.target
For "every N minutes after the previous run finishes" use a monotonic trigger instead of OnCalendar — this guarantees the interval is measured from completion, not scheduled time, so a long-running job can never overlap itself:
[Timer]
OnBootSec=15min # first run, 15 min after boot
OnUnitActiveSec=1h # then every hour after the last completion
Enable and inspect:
systemctl --user daemon-reload
systemctl --user enable --now backup.timer
systemctl --user list-timers --all
journalctl --user -u backup.service --since "1 day ago"
Output (systemctl --user list-timers --all):
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2026-05-25 03:00:23 EDT 16h left Sat 2026-05-24 03:00:00 EDT 7h ago backup.timer backup.service
See the systemd units cheatsheet for the full feature set.
at — one-shot scheduling
at schedules a single future execution rather than a recurring one. The package is often not installed by default; install it on Debian/Ubuntu with sudo apt install at and make sure atd is running.
# Run at 09:30 tomorrow
echo '/home/alice/bin/report.sh' | at 09:30 tomorrow
# Run in 15 minutes
echo 'shutdown -h now' | sudo at now + 15 minutes
# List scheduled jobs
atq
# Show a queued job's command
at -c 3
# Remove a queued job by number
atrm 3
Output (atq):
3 Sun May 25 09:30:00 2026 a alice
4 Sun May 25 17:00:00 2026 a alice
Logging and auditing cron itself
cron writes its own activity (which jobs it started, syntax errors, mail-delivery problems) to syslog. On systemd hosts, view it with journalctl; on traditional setups, look in /var/log/cron or /var/log/syslog.
# What did cron do today?
sudo journalctl -u cron --since today # Debian/Ubuntu
sudo journalctl -u crond --since today # Fedora/RHEL
# Last 50 cron lines from syslog
sudo grep -i cron /var/log/syslog | tail -50
# Watch live
sudo journalctl -u cron -f
Output:
May 24 03:00:01 myhost CRON[2104]: (alice) CMD (/home/alice/bin/backup.sh)
May 24 03:00:01 myhost CRON[2103]: (CRON) info (No MTA installed, discarding output)
The second line is the most common cron diagnostic: there's no local mail transport, so any output the job produced was thrown away. Redirect to a file or send notifications from inside the job.
Common pitfalls
- Job runs interactively but not from cron — almost always a
$PATHproblem. Use absolute paths or setPATH=at the top of the crontab. - No newline at end of file — Vixie cron silently ignores the last line if it doesn't end with a newline.
crontab -eadds one for you; hand-edited files sometimes don't. - Percent signs in commands —
%is special inside crontab commands; it gets translated to a newline. Escape it as\%or quote the whole command. - Day-of-month AND day-of-week — in classic cron, if both fields are restrictive (not
*), the job runs whenever either matches, not both.0 0 1 * monfires on the 1st and every Monday, not "Mondays that fall on the 1st". - DST jumps — at the spring-forward jump, jobs scheduled inside the skipped hour don't run. At the fall-back jump, jobs in the repeated hour run twice on Vixie cron (cronie skips the repeat). Use
TZ=UTCfor jobs that must run exactly once. - MAILTO with no MTA — output is silently discarded and you don't realise the job is failing. Redirect to a log file, or use a healthchecks.io-style monitor.
@rebootdoesn't always run early — it runs when cron starts, not when the kernel boots. If you need "as soon as the network is up", use a systemd unit withAfter=network-online.target.- Cron edits not taking effect — confirm with
crontab -l(after-e) that the changes were saved. If you exit your editor without writing, cron sees no change and prints nothing.
Real-world recipes
Daily backup with logging and failure alert
A common pattern: run a script nightly, log everything to a file, and ping a healthcheck URL on success or failure so you find out when it stops working.
# crontab -e
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
# Run at 03:00 daily, log everything, and ping a healthcheck
0 3 * * * /home/alice/bin/backup.sh
Output: (none — exits 0 on success)
#!/usr/bin/env bash
# /home/alice/bin/backup.sh
set -euo pipefail
LOG=/home/alice/log/backup.log
URL=https://hc-ping.com/abc-123
ts() { date '+%Y-%m-%d %H:%M:%S'; }
{
echo "[$(ts)] starting"
if rsync -aH --delete /home/alice/data/ /srv/backups/data/; then
echo "[$(ts)] OK"
curl -fsS --retry 3 "$URL" > /dev/null
else
echo "[$(ts)] FAILED"
curl -fsS --retry 3 "$URL/fail" > /dev/null
exit 1
fi
} >> "$LOG" 2>&1
Output: (none — exits 0 on success)
Run every weekday during business hours
A poller that should only run when the office is open.
*/15 9-17 * * mon-fri /home/alice/bin/poll-feeds.sh >> /home/alice/log/poll.log 2>&1
Output: (none — exits 0 on success)
Rotate a log file weekly
A homegrown rotation when you don't want to configure logrotate. Keep four weeks of history.
0 0 * * sun /home/alice/bin/rotate.sh
Output: (none — exits 0 on success)
#!/usr/bin/env bash
# /home/alice/bin/rotate.sh
set -euo pipefail
LOG=/home/alice/log/app.log
[[ -f "$LOG" ]] || exit 0
mv "$LOG.3" "$LOG.4" 2>/dev/null || true
mv "$LOG.2" "$LOG.3" 2>/dev/null || true
mv "$LOG.1" "$LOG.2" 2>/dev/null || true
mv "$LOG" "$LOG.1"
: > "$LOG"
Output: (none — exits 0 on success)
Test a job immediately without changing the schedule
Run the exact command from your crontab with the same trimmed environment cron will use, to catch $PATH and missing-variable bugs before they fire at 3 a.m.
# Strip the environment and run as your user
env -i HOME=$HOME PATH=/usr/bin:/bin SHELL=/bin/bash \
/home/alice/bin/backup.sh
Output: (none — exits 0 on success)
Run a one-off cleanup tomorrow morning
For "do this once, not every day", at is simpler than adding-and-removing a crontab entry.
echo '/home/alice/bin/cleanup.sh' | at 06:00 tomorrow
atq
Output:
5 Sun May 25 06:00:00 2026 a alice
Convert a cron job to a systemd timer
When the cron job has outgrown cron — needs logging, retries, dependency on the network — translate it to a timer.
# Before (crontab):
# 0 3 * * * /home/alice/bin/backup.sh
# After: ~/.config/systemd/user/backup.service + .timer (see the systemd
# section above). Enable with:
systemctl --user enable --now backup.timer
# Then verify the next run:
systemctl --user list-timers backup.timer
Output:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2026-05-25 03:00:00 EDT 16h left Sat 2026-05-24 03:00:00 EDT 7h ago backup.timer backup.service
Treat the crontab as code. Keep an authoritative copy in version control and install it with
crontab path/to/jobs.cron. Don't rely on remembering what's installed on a given host.
[!WARN] Long-running jobs that don't use
flockwill eventually overlap themselves. Either bound the runtime with a timeout (timeout 30m cmd) or wrap the command inflock -n /var/lock/<job>.lock.