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

bash
# 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.

PathPurpose
/etc/crontabMain 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.allowAllowlist of users who may use crontab. If present, only these users may schedule jobs.
/etc/cron.denyDenylist of users who may not use crontab. Consulted only when cron.allow is absent.
/etc/anacrontabanacron schedule for catch-up runs on machines that aren't always on.
/var/log/cron or syslog/journaldCron'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.allow nor cron.deny, so any user may schedule jobs. RHEL/Fedora ships an empty cron.deny. To lock crontab access to a single user, create /etc/cron.allow containing 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.

text
┌──── 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

SyntaxMeans
*every value
5exact value
1,15,30list of values
1-5range (inclusive)
*/15step (every 15 from the field's minimum)
0-30/5step within a range
mon-frirange of named values (Vixie/cronie)

Common schedules

CrontabRuns
* * * * *every minute
*/5 * * * *every 5 minutes
0 * * * *hourly, on the hour
0 3 * * *every day at 03:00
0 3 * * mon-friweekdays 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-5hourly during business hours, weekdays
15,45 * * * *at :15 and :45 every hour
bash
# 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.

ShortcutEquivalent
@yearly / @annually0 0 1 1 *
@monthly0 0 1 * *
@weekly0 0 * * 0
@daily / @midnight0 0 * * *
@hourly0 * * * *
@rebootonce at cron daemon startup
bash
@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.

bash
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:

text
# 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 -r deletes the file with no confirmation and no undo. Always back up first with crontab -l > /home/alice/crontab.bak, or use crontab -i -r so 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.

PathPurpose
/etc/crontabmain 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:

text
# /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.

bash
# 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:

bash
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.py in a crontab will likely fail with command not found; /usr/bin/python3 /home/alice/script.py will 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.

bash
# 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:

bash
#!/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.

text
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.

bash
#!/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.

bash
# 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)

bash
# 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 -n is 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:

text
# /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:

ini
# ~/.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%
ini
# ~/.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:

ini
[Timer]
OnBootSec=15min          # first run, 15 min after boot
OnUnitActiveSec=1h       # then every hour after the last completion

Enable and inspect:

bash
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):

text
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.

bash
# 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):

text
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.

bash
# 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:

text
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

  1. Job runs interactively but not from cron — almost always a $PATH problem. Use absolute paths or set PATH= at the top of the crontab.
  2. No newline at end of file — Vixie cron silently ignores the last line if it doesn't end with a newline. crontab -e adds one for you; hand-edited files sometimes don't.
  3. Percent signs in commands% is special inside crontab commands; it gets translated to a newline. Escape it as \% or quote the whole command.
  4. 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 * mon fires on the 1st and every Monday, not "Mondays that fall on the 1st".
  5. 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=UTC for jobs that must run exactly once.
  6. 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.
  7. @reboot doesn'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 with After=network-online.target.
  8. 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.

bash
# 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)

bash
#!/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.

bash
*/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.

bash
0 0 * * sun  /home/alice/bin/rotate.sh

Output: (none — exits 0 on success)

bash
#!/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.

bash
# 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.

bash
echo '/home/alice/bin/cleanup.sh' | at 06:00 tomorrow
atq

Output:

text
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.

bash
# 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:

text
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 flock will eventually overlap themselves. Either bound the runtime with a timeout (timeout 30m cmd) or wrap the command in flock -n /var/lock/<job>.lock.

Sources