Introduction

Cron is the classic Unix job scheduler β€” a background service that runs commands on a fixed time schedule. You define when a command should run (every hour, every Monday at midnight, the first day of each month…) and cron just does it, reliably, without you having to think about it again.

It was first introduced in Version 7 Unix in 1979, written by Brian Kernighan and Lorinda Cherry, and has been a fixture of every Unix-like system since. The name comes from the Greek word chronos (time). Today, virtually every Linux distribution ships with a cron implementation β€” most commonly Vixie Cron (the standard on RHEL/CentOS/Fedora), cronie (a Vixie fork), or dcron/fcron on lighter distros. They all speak the same crontab language.

Common use cases for cron:

  • Nightly database backups or file archiving
  • Sending scheduled reports or digest emails
  • Certificate renewal (e.g. certbot renew)
  • Log rotation triggers and old-file cleanup
  • Syncing data between systems on a schedule
  • Monitoring scripts that check health periodically

When should you not use cron? If you need sub-minute precision, complex dependency management between jobs, or distributed scheduling across multiple machines, reach for a dedicated tool like systemd timers, Celery Beat, or a workflow orchestrator. But for the vast majority of "run this command every N minutes/hours/days" needs, cron is still the right tool.

How Cron Works

Cron is implemented as a daemon β€” crond β€” that starts at boot and runs in the background indefinitely. Every minute, it wakes up, reads all the crontab files on the system, checks whether any scheduled job matches the current minute/hour/day/month/weekday, and if so, forks a child process to execute that command. Then it goes back to sleep until the next minute.

The daemon reads job definitions from several locations:

LocationPurposeHas username field?
/var/spool/cron/crontabs/<user>Per-user crontabs (edited via crontab -e)No β€” implicitly the owning user
/etc/crontabSystem-wide crontab, maintained by adminsYes
/etc/cron.d/Drop-in system cron jobs (package-installed jobs live here)Yes
/etc/cron.hourly/Scripts run by run-parts every hourN/A β€” scripts, not crontab lines
/etc/cron.daily/Scripts run once a dayN/A
/etc/cron.weekly/Scripts run once a weekN/A
/etc/cron.monthly/Scripts run once a monthN/A

The /etc/cron.{hourly,daily,weekly,monthly}/ directories work via run-parts β€” a small utility that executes every executable file in a directory. The timing for those directories is typically defined in /etc/crontab or /etc/cron.d/0hourly (distro-dependent). Files placed there must be executable and must not have a dot in their name (e.g. backup.sh would be skipped by run-parts on some distros β€” name it backup instead).

πŸ’‘ How crond detects changes Modern cron implementations (cronie, dcron) watch for crontab file modifications using inotify or by checking file modification times, so they pick up changes immediately without needing a restart.

Cron Syntax Deep Dive

A user crontab entry has five time fields followed by the command to run:

# β”Œβ”€β”€β”€β”€ minute      (0–59)
# β”‚  β”Œβ”€β”€β”€ hour        (0–23)
# β”‚  β”‚  β”Œβ”€β”€ day-of-month (1–31)
# β”‚  β”‚  β”‚  β”Œβ”€ month       (1–12  or  jan–dec)
# β”‚  β”‚  β”‚  β”‚  β”Œ day-of-week  (0–7  or  sun–sat,  0 and 7 = Sunday)
# β”‚  β”‚  β”‚  β”‚  β”‚
  *  *  *  *  *  /path/to/command --args

Field operators

OperatorMeaningExample
*Any / every value* in hour β†’ every hour
a-bRange (inclusive)1-5 in dow β†’ Mon–Fri
*/nStep β€” every n-th value*/15 in min β†’ :00 :15 :30 :45
a-b/nStep within a range8-18/2 in hour β†’ 8,10,12,14,16,18
a,b,cList of values0,6,12,18 in hour β†’ 4Γ— daily

Special strings

Many cron implementations support convenient shorthand strings as a replacement for all five fields:

StringEquivalentMeaning
@rebootβ€”Run once at daemon startup
@yearly / @annually0 0 1 1 *January 1st at midnight
@monthly0 0 1 * *First day of each month, midnight
@weekly0 0 * * 0Every Sunday at midnight
@daily / @midnight0 0 * * *Every day at midnight
@hourly0 * * * *At the start of every hour

Worked examples

# Every 5 minutes
*/5  *    *   *   *   /usr/bin/check-health.sh

# At 2:30 AM every day
30   2    *   *   *   /opt/scripts/nightly-backup.sh

# Every weekday at 9 AM
0    9    *   *  1-5  /usr/bin/send-standup-reminder.py

# First day of each month at midnight
0    0    1   *   *   /opt/billing/monthly-invoice.sh

# Every 15 minutes between 8 AM and 6 PM on weekdays
*/15 8-18  *   *  1-5  /usr/bin/sync-data.sh

# Once at boot
@reboot  /usr/local/bin/start-my-service.sh
⚠️ Day-of-month vs day-of-week When you specify both a non-wildcard day-of-month and a non-wildcard day-of-week, cron treats them as a union (OR), not an intersection. 0 0 1 * 5 means "midnight on the 1st of the month or any Friday" β€” which surprises most people.

Use crontab.guru to visually verify any schedule before deploying it.

Managing Crontabs

User crontabs are managed through the crontab command. You should never edit the files in /var/spool/cron/ directly β€” always go through crontab so the daemon is properly notified and syntax is validated.

CommandWhat it does
crontab -eOpen your crontab in an editor (creates one if it doesn't exist)
crontab -lList your current crontab
crontab -rRemove (delete) your crontab entirely
crontab -iPrompt before removing (use with -r: crontab -ir)
sudo crontab -u alice -eEdit user alice's crontab as root
sudo crontab -u alice -lList another user's crontab

Choosing your editor

crontab -e uses whatever editor is set in the EDITOR (or VISUAL) environment variable. Set it in your shell profile to avoid being dropped into vi unexpectedly:

# In ~/.bashrc or ~/.zshrc
export EDITOR=nano   # or vim, micro, code, etc.

Programmatic crontab editing

In scripts or config management you often need to add a line without launching an interactive editor. The safest pattern is:

# Add a line only if it doesn't already exist
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/backup.sh") | crontab -

The 2>/dev/null silences the "no crontab for user" message on a fresh system. Piping to crontab - reads the new crontab from stdin.

Crontab file location by distro

  • Debian/Ubuntu: /var/spool/cron/crontabs/<user>
  • RHEL/CentOS/Fedora: /var/spool/cron/<user>
  • macOS (for reference): /var/at/tabs/<user>

System Cron vs User Cron

There are two distinct types of cron configurations and it's important not to confuse them.

User crontabs (/var/spool/cron/)

Created and managed by ordinary users via crontab -e. Jobs run as that user. The five-field format is used β€” there is no username column because the owning user is implied.

System crontab (/etc/crontab)

A single file managed by the system administrator. It uses a six-field format where the sixth field (before the command) specifies the user to run the job as:

# /etc/crontab β€” six-field format
# min  hour  dom  mon  dow  user      command
  17   *     *    *    *    root      cd / && run-parts --report /etc/cron.hourly
   25  6     *    *    *    root      test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )
   47  6     *    *    7    root      test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly )
   52  6     1    *    *    root      test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )

Drop-in jobs (/etc/cron.d/)

Files in this directory use the same six-field format as /etc/crontab. Package managers place jobs here (e.g. /etc/cron.d/certbot, /etc/cron.d/php). They are read by crond directly, so you can drop a file in and it becomes active without restarting cron.

# /etc/cron.d/myapp β€” drop-in, six-field format
0  3  *  *  *  deploy  /opt/myapp/scripts/cleanup.sh >> /var/log/myapp-cleanup.log 2>&1

run-parts directories

The /etc/cron.{hourly,daily,weekly,monthly}/ directories hold plain executable scripts (not crontab lines). When cron's trigger fires, it calls run-parts /etc/cron.daily/, which executes every file in that directory sequentially. Keep these rules in mind:

  • Files must be executable (chmod +x)
  • File names must match ^[a-zA-Z0-9_-]+$ β€” no dots, no tildes, no extensions on most distros (Debian's run-parts supports --lsbsysinit which is more permissive)
  • Output is mailed to root (or whoever MAILTO is set to) unless redirected
βœ… Best practice Prefer /etc/cron.d/ over editing /etc/crontab directly. Drop-in files are easier to manage, package, and audit β€” and you won't accidentally break the system crontab with a syntax error.

Environment & PATH Gotchas

This is the number-one reason cron jobs fail silently. When cron runs your command, it does not source your shell's profile (~/.bashrc, ~/.profile, etc.) and does not use your login shell. The environment is nearly empty β€” no shell aliases, no shell functions, and critically, a minimal PATH.

The default cron PATH

Depending on the cron implementation, the default PATH is something like:

/usr/bin:/bin

That means /usr/local/bin, /usr/sbin, and anything in your home directory's bin/ are all absent. A command that works perfectly in your terminal might not be found by cron at all.

Fix 1 β€” Set PATH at the top of your crontab

# At the very top of your crontab (before any job lines)
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/homebrew/bin

# Now this works even if /usr/local/bin isn't in the default PATH
0 2 * * *  /usr/local/bin/node /opt/myapp/job.js

Fix 2 β€” Use absolute paths everywhere

The safest habit: use the full path to every binary in your cron commands. Run which node, which python3, etc. in your terminal to find the exact path.

# Fragile β€” depends on PATH containing ruby's location
0 4 * * *  ruby /opt/scripts/report.rb

# Robust β€” explicit path
0 4 * * *  /usr/bin/ruby /opt/scripts/report.rb

Other environment variables

You can set any variable at the top of a crontab, one per line:

SHELL=/bin/bash
HOME=/home/soumya
MAILTO=soumya@example.com
TZ=Asia/Kathmandu

0 6 * * *  /opt/scripts/morning-report.sh
VariableEffect
MAILTOEmail address to send job output to. Set to "" to silence all emails.
SHELLShell used to execute commands (default: /bin/sh). Set to /bin/bash if you use bash-specific syntax.
HOMEOverrides the home directory for the job.
TZTimezone for interpreting the schedule (e.g. America/New_York). Supported by Vixie cron and cronie.
⚠️ Bash vs sh Cron uses /bin/sh by default. If your script uses bash-isms ([[, arrays, source, etc.) it will silently fail or behave wrongly. Either add SHELL=/bin/bash to your crontab or put #!/bin/bash at the top of every script you call.

Logging & Debugging

Where cron logs

Cron records job execution (start, exit code) to the system log. The location varies by distro:

  • Debian/Ubuntu: /var/log/syslog β€” filter with grep CRON /var/log/syslog
  • RHEL/CentOS/Fedora: /var/log/cron
  • Systems with systemd-journald: journalctl -u cron or journalctl -u crond

A typical log entry looks like:

Jun 01 02:30:01 myhost CRON[12345]: (soumya) CMD (/opt/scripts/nightly-backup.sh)

This confirms cron ran the command. If you don't see an entry here for an expected job, the issue is with the schedule definition, not the script itself.

Capturing command output

By default cron emails stdout/stderr to the job owner. If mail is not configured, output is silently discarded. Always redirect output explicitly:

# Append stdout and stderr to a log file
0 2 * * *  /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

# Separate stdout and stderr
0 2 * * *  /opt/scripts/backup.sh >> /var/log/backup.log 2>/var/log/backup-errors.log

# Completely silent (discard all output)
0 2 * * *  /opt/scripts/backup.sh > /dev/null 2>&1
βœ… Add timestamps to your log files Wrap your command to prepend a timestamp on each run:

0 2 * * * echo "=== $(date) ===" >> /var/log/backup.log; /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

Suppress emails globally

Set MAILTO="" at the top of the crontab to disable email for all jobs in that file. Set it just before an individual job line to silence only that one.

Testing a job as cron would run it

The most reliable way to reproduce the cron environment locally is to strip the environment and run with a minimal PATH, exactly as crond does:

# Simulate cron's environment for debugging
env -i HOME=/home/soumya \
       LOGNAME=soumya \
       PATH=/usr/bin:/bin \
       SHELL=/bin/sh \
       /bin/sh -c "/opt/scripts/backup.sh >> /tmp/test.log 2>&1"

If it fails here but works in your normal shell, the issue is almost certainly a missing PATH entry or an unset environment variable.

Alternatives: anacron & systemd timers

anacron β€” for machines that aren't always on

Classic cron assumes the machine is running continuously. If the system is off at the scheduled time, the job is simply missed. anacron solves this: it runs missed jobs when the machine next boots (or when anacron itself is next run). It is ideal for laptops, desktops, or servers that may occasionally be powered down.

anacron is configured in /etc/anacrontab:

# /etc/anacrontab
# period(days)  delay(min)  job-id           command
  1              5           cron.daily        run-parts /etc/cron.daily
  7              10          cron.weekly       run-parts /etc/cron.weekly
  @monthly       15          cron.monthly      run-parts /etc/cron.monthly

The delay column prevents all missed jobs from hammering the system simultaneously on boot. anacron records last-run times in /var/spool/anacron/.

The limitation: anacron only supports day-level granularity β€” you can't schedule something "every 5 minutes" with anacron. Use it for daily/weekly/monthly tasks on non-server systems.

systemd timers β€” modern, powerful, logged

systemd timers are the most capable alternative. Every timer is paired with a .service unit, so you get full journald logging, dependency management, and the ability to start on boot, on a calendar schedule, or relative to another event.

A basic example: run a backup every day at 2 AM.

1. Create the service unit at /etc/systemd/system/backup.service:

[Unit]
Description=Nightly database backup

[Service]
Type=oneshot
User=deploy
ExecStart=/opt/scripts/backup.sh
StandardOutput=journal
StandardError=journal

2. Create the timer unit at /etc/systemd/system/backup.timer:

[Unit]
Description=Run backup daily at 2 AM

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true      # Run if missed (like anacron)
RandomizedDelaySec=5min  # Spread load on multi-machine setups

[Install]
WantedBy=timers.target

3. Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers --all   # See all timers and next run times

4. Check logs:

journalctl -u backup.service    # Full output from the last run
cronanacronsystemd timers
Min granularity1 minute1 day1 second (or microsecond)
Missed jobsSkippedRuns on next bootRuns on next activation (with Persistent=true)
LoggingSystem log (basic)System logjournald (full stdout/stderr)
DependenciesNoneNoneFull systemd dependency graph
Learning curveLowLowMedium

Real-World Examples

####################################################
# Environment β€” set once at the top
####################################################
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=admin@example.com

####################################################
# PostgreSQL dump β€” nightly at 2:15 AM
####################################################
15 2 * * *  /usr/bin/pg_dump -U postgres myapp | \
             /usr/bin/gzip > /backups/myapp-$(date +\%F).sql.gz

####################################################
# Certbot SSL renewal β€” twice daily (certbot
# itself checks if renewal is actually needed)
####################################################
0 0,12 * * *  /usr/bin/certbot renew --quiet >> /var/log/certbot.log 2>&1

####################################################
# Delete files older than 30 days from /tmp
####################################################
0 3 * * *  /usr/bin/find /tmp -type f -mtime +30 -delete

####################################################
# Clear application cache every hour
####################################################
0 * * * *  /usr/bin/redis-cli FLUSHDB >> /var/log/cache-flush.log 2>&1

####################################################
# Weekly report β€” Monday at 8 AM
####################################################
0 8 * * 1  /opt/myapp/venv/bin/python /opt/myapp/reports/weekly.py \
             >> /var/log/weekly-report.log 2>&1

####################################################
# rsync backup to remote β€” nightly at 1 AM
####################################################
0 1 * * *  /usr/bin/rsync -avz --delete /var/www/ \
             backup@192.168.1.50:/mnt/backups/www/ \
             >> /var/log/rsync-backup.log 2>&1

####################################################
# Rotate / compress logs older than 7 days
####################################################
30 2 * * *  /usr/bin/find /var/log/myapp -name "*.log" -mtime +7 \
             -exec gzip {} \;
⚠️ Percent signs in cron commands The % character is special in crontab β€” it is treated as a newline. In the date +%F example above, each % must be escaped as \%. This is a very common gotcha.

Security Considerations

Restricting cron access

Two files control which users are allowed to use cron at all. The logic is:

  • If /etc/cron.allow exists, only users listed in it may use cron.
  • If /etc/cron.allow does not exist but /etc/cron.deny does, any user not in that file may use cron.
  • If neither file exists, only root may use cron (on some implementations) or all users may (on others β€” check your distro's default).
# /etc/cron.allow β€” whitelist approach (recommended for servers)
root
deploy
backup-user

World-writable scripts are a privilege escalation path

If a cron job runs a script as root, and that script is writable by a non-root user, any user can modify the script and have their code execute as root on the next cron trigger. Always verify:

# Check ownership and permissions of a cron'd script
ls -la /opt/scripts/backup.sh
# Should be: -rwxr-x--- root root  or  -rwxr-xr-x root root
# NOT: -rwxrwxrwx (world-writable) β€” that's a vulnerability

Auditing crontabs

Adversaries and malware commonly persist on a system by adding entries to crontab. Periodically audit all cron locations:

# List all user crontabs
for user in $(cut -d: -f1 /etc/passwd); do
  crontab -u "$user" -l 2>/dev/null && echo "--- $user ---"
done

# Check system cron locations
cat /etc/crontab
ls -la /etc/cron.d/
ls -la /etc/cron.{hourly,daily,weekly,monthly}/

Common CTF / pentest vector

Privilege escalation via cron is a staple of capture-the-flag challenges and real-world penetration tests. The typical finding is a root-owned cron job that calls a world-writable script or a script in a world-writable directory. Tools like pspy can monitor cron job execution in real time without root privileges β€” useful for defenders auditing their own systems and a reminder that cron job execution is observable by local users.

βœ… Security checklist
  • All scripts called by root cron jobs are owned by root and not world-writable
  • Scripts are in directories that are not world-writable
  • Use /etc/cron.allow to whitelist only necessary users
  • Regularly audit all five cron locations for unexpected entries
  • Avoid storing secrets in crontab directly β€” use environment files or a secrets manager