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:
| Location | Purpose | Has username field? |
|---|---|---|
/var/spool/cron/crontabs/<user> | Per-user crontabs (edited via crontab -e) | No β implicitly the owning user |
/etc/crontab | System-wide crontab, maintained by admins | Yes |
/etc/cron.d/ | Drop-in system cron jobs (package-installed jobs live here) | Yes |
/etc/cron.hourly/ | Scripts run by run-parts every hour | N/A β scripts, not crontab lines |
/etc/cron.daily/ | Scripts run once a day | N/A |
/etc/cron.weekly/ | Scripts run once a week | N/A |
/etc/cron.monthly/ | Scripts run once a month | N/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).
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
| Operator | Meaning | Example |
|---|---|---|
* | Any / every value | * in hour β every hour |
a-b | Range (inclusive) | 1-5 in dow β MonβFri |
*/n | Step β every n-th value | */15 in min β :00 :15 :30 :45 |
a-b/n | Step within a range | 8-18/2 in hour β 8,10,12,14,16,18 |
a,b,c | List of values | 0,6,12,18 in hour β 4Γ daily |
Special strings
Many cron implementations support convenient shorthand strings as a replacement for all five fields:
| String | Equivalent | Meaning |
|---|---|---|
@reboot | β | Run once at daemon startup |
@yearly / @annually | 0 0 1 1 * | January 1st at midnight |
@monthly | 0 0 1 * * | First day of each month, midnight |
@weekly | 0 0 * * 0 | Every Sunday at midnight |
@daily / @midnight | 0 0 * * * | Every day at midnight |
@hourly | 0 * * * * | 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
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.
| Command | What it does |
|---|---|
crontab -e | Open your crontab in an editor (creates one if it doesn't exist) |
crontab -l | List your current crontab |
crontab -r | Remove (delete) your crontab entirely |
crontab -i | Prompt before removing (use with -r: crontab -ir) |
sudo crontab -u alice -e | Edit user alice's crontab as root |
sudo crontab -u alice -l | List 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'srun-partssupports--lsbsysinitwhich is more permissive) - Output is mailed to root (or whoever
MAILTOis set to) unless redirected
/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
| Variable | Effect |
|---|---|
MAILTO | Email address to send job output to. Set to "" to silence all emails. |
SHELL | Shell used to execute commands (default: /bin/sh). Set to /bin/bash if you use bash-specific syntax. |
HOME | Overrides the home directory for the job. |
TZ | Timezone for interpreting the schedule (e.g. America/New_York). Supported by Vixie cron and cronie. |
/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 withgrep CRON /var/log/syslog - RHEL/CentOS/Fedora:
/var/log/cron - Systems with systemd-journald:
journalctl -u cronorjournalctl -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
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
| cron | anacron | systemd timers | |
|---|---|---|---|
| Min granularity | 1 minute | 1 day | 1 second (or microsecond) |
| Missed jobs | Skipped | Runs on next boot | Runs on next activation (with Persistent=true) |
| Logging | System log (basic) | System log | journald (full stdout/stderr) |
| Dependencies | None | None | Full systemd dependency graph |
| Learning curve | Low | Low | Medium |
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 {} \;
% 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.allowexists, only users listed in it may use cron. - If
/etc/cron.allowdoes not exist but/etc/cron.denydoes, 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.
- 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.allowto 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