cron & systemd Timers
- What cron is
- crontab syntax
- Editing crontabs
- Special strings
- System-wide cron files
- cron environment
- systemd timers
- Timer unit anatomy
- Managing timers
- cron vs systemd timers
- Ansible
- Troubleshooting
- MAILTO — controlling cron email
- flock — prevent overlapping runs
- cron.allow / cron.deny
- CRON_TZ — per-job timezones
- DST caveats
- @reboot pitfalls
What cron is
cron is a daemon that runs scheduled commands at specified times. It reads crontab (cron table) files — one per user plus system-wide files — and runs the listed commands when the schedule matches the current time. The daemon itself is usually crond (RHEL) or cron (Debian).
crontab syntax
# ┌─────────────── minute (0–59)
# │ ┌──────────── hour (0–23)
# │ │ ┌───────── day of month (1–31)
# │ │ │ ┌────── month (1–12)
# │ │ │ │ ┌─── day of week (0–7, 0 and 7 = Sunday)
# │ │ │ │ │
# * * * * * command to run
# Run at 2:30 AM every day
30 2 * * * /usr/local/bin/backup.sh
# Run every 15 minutes
*/15 * * * * /usr/local/bin/check_disk.sh
# Run at midnight on the 1st of every month
0 0 1 * * /usr/local/bin/monthly_report.sh
# Run at 9 AM Monday–Friday
0 9 * * 1-5 /usr/local/bin/work_hours_job.sh
# Run every hour on weekdays
0 * * * 1-5 /usr/local/bin/hourly_check.sh
The * wildcard means "every value in this field." */15 means "every 15 units." A range like 1-5 means Monday through Friday. A comma-separated list like 1,3,5 means Monday, Wednesday, Friday.
Editing crontabs
# Edit your own crontab (opens in $EDITOR)
crontab -e
# List your current crontab
crontab -l
# Remove your crontab entirely
crontab -r
# Edit another user's crontab (as root)
crontab -u alice -e
# List another user's crontab
crontab -u alice -l
crontab -e, never edit /var/spool/cron/ files directly. Direct edits bypass syntax checking and may not be picked up by the daemon correctly.
Special strings
These replace the five time fields entirely:
@reboot # Run once at system startup
@hourly # Same as: 0 * * * *
@daily # Same as: 0 0 * * *
@midnight # Same as: 0 0 * * *
@weekly # Same as: 0 0 * * 0
@monthly # Same as: 0 0 1 * *
@yearly # Same as: 0 0 1 1 *
# Practical examples
@reboot /usr/local/bin/on_startup.sh
@daily /usr/local/bin/log_rotate.sh >> /var/log/rotate.log 2>&1
System-wide cron files
Root-owned jobs go in /etc/cron.d/ as named files (not in root's personal crontab). These files have an extra field: the user to run the command as.
# Format in /etc/cron.d/myjob:
# min hour dom month dow USER command
0 3 * * * root /usr/local/bin/nightly_backup.sh
# Also available — drop scripts into these directories:
/etc/cron.hourly/
/etc/cron.daily/
/etc/cron.weekly/
/etc/cron.monthly/
Scripts dropped in /etc/cron.daily/ are run by run-parts at a system-defined time (usually around 6 AM on RHEL, via /etc/cron.d/0hourly or anacron). They must be executable and have no file extension on RHEL.
cron environment
cron runs commands in a minimal environment — no PATH aliases, no interactive shell sourcing. This is the source of most "works on command line, fails in cron" problems.
# Set PATH at the top of your crontab
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# Log stdout and stderr to a file (cron discards output otherwise or emails it)
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
# Test what environment cron uses
* * * * * env > /tmp/cron_env.txt
/usr/bin/python3, not python3. And redirect output to a log file. Cron will try to email unhandled output, which usually fails silently.
systemd timers
A systemd timer is an alternative to cron that runs a paired service unit at scheduled times. Advantages over cron: logs go to the journal, dependencies can be expressed, missed runs are tracked, and you can run once immediately with systemctl start.
A timer consists of two unit files: a .timer that defines the schedule, and a .service that defines what to run. They must have the same base name.
Timer unit anatomy
# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup
[Timer]
OnCalendar=*-*-* 02:30:00 # every day at 02:30
Persistent=true # catch up if the system was off at scheduled time
RandomizedDelaySec=300 # add up to 5 min random delay to spread load
[Install]
WantedBy=timers.target
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup job
[Service]
Type=oneshot # exits after running — correct for scheduled tasks
ExecStart=/usr/local/bin/backup.sh
User=backup # run as this user, not root
Type=oneshot is correct for tasks that run and exit. Persistent=true means if the timer missed its window (system was off), it runs as soon as possible after boot.
OnCalendar expressions
OnCalendar=hourly # every hour at :00
OnCalendar=daily # every day at 00:00
OnCalendar=weekly # every Monday at 00:00
OnCalendar=monthly # 1st of month at 00:00
OnCalendar=*-*-* 02:30:00 # every day at 02:30
OnCalendar=Mon *-*-* 09:00:00 # every Monday at 09:00
OnCalendar=*-*-* *:0/15:00 # every 15 minutes
# Test expressions without creating a unit
systemd-analyze calendar "Mon *-*-* 09:00:00"
Monotonic timers (relative time)
# These run relative to a system event, not a calendar time
OnBootSec=5min # 5 minutes after boot
OnStartupSec=10min # 10 minutes after systemd starts
OnActiveSec=1h # 1 hour after the timer itself was activated
OnUnitActiveSec=30min # 30 minutes after the service last ran
Managing timers
# Enable and start a timer (survives reboots)
systemctl daemon-reload
systemctl enable --now backup.timer
# List all active timers (shows next/last trigger)
systemctl list-timers
# List all timers including inactive
systemctl list-timers --all
# Run the service immediately (without waiting for schedule)
systemctl start backup.service
# View timer status and recent runs
systemctl status backup.timer
# View logs from the paired service
journalctl -u backup.service
journalctl -u backup.service --since today
cron vs systemd timers
# Use cron when:
# - Simple one-line jobs
# - Per-user jobs (cron has per-user crontabs; systemd user units are more complex)
# - Portability across distros matters
# - Quick additions without creating unit files
# Use systemd timers when:
# - You need logs in journald (searchable, structured)
# - The job has systemd service dependencies (e.g. needs network)
# - You want to track missed runs (Persistent=true)
# - You need to test by running immediately (systemctl start)
# - You already manage services with Ansible and want consistency
Ansible
---
# Managing cron jobs with ansible.builtin.cron
- name: Add nightly backup cron job
ansible.builtin.cron:
name: "nightly backup" # description / comment in crontab
minute: "30"
hour: "2"
job: "/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1"
user: root
state: present
# Managing systemd timers with ansible.builtin.systemd
- name: Deploy backup timer unit
ansible.builtin.template:
src: backup.timer.j2
dest: /etc/systemd/system/backup.timer
- name: Deploy backup service unit
ansible.builtin.template:
src: backup.service.j2
dest: /etc/systemd/system/backup.service
- name: Enable backup timer
ansible.builtin.systemd:
name: backup.timer
enabled: true
state: started
daemon_reload: true
Troubleshooting
# cron job not running
# 1. Check the cron daemon is running
systemctl status crond # RHEL
systemctl status cron # Debian
# 2. Check cron logs
journalctl -u crond
grep CRON /var/log/syslog # Debian fallback
# 3. Verify the job runs manually (full path, minimal env)
PATH=/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin
/usr/local/bin/backup.sh
# 4. Check file permissions and ownership
ls -la /usr/local/bin/backup.sh
# Must be executable (chmod +x), correct owner
# systemd timer not firing
# 1. Check timer is active and see next trigger
systemctl list-timers backup.timer
# 2. Check for errors in unit files
systemctl status backup.timer
journalctl -u backup.timer
# 3. Run the service immediately to test
systemctl start backup.service
journalctl -u backup.service -f
MAILTO — controlling cron email
Cron emails any stdout/stderr output from a job to the local user account (or to MAILTO if set). This is why "silent" failures happen — if mail is not configured or goes to an unread mailbox, you never see the error.
# In a crontab (applies to all jobs that follow it)
# Send output to an email address
MAILTO=ops-team@example.com
# Suppress all email output (common for noisy but harmless jobs)
MAILTO=""
# Send to multiple addresses
MAILTO="alice@example.com,bob@example.com"
MAILTO="" is the most common setting in production crontabs. Suppressing email prevents noise but means you must rely on exit code monitoring, logging, or alerting instead. Always ensure important jobs log failures explicitly before suppressing email.
Best practice: redirect output explicitly
# Log stdout and stderr to a file — gives you a record even with MAILTO=""
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
# Log and still email on error (more complex, requires script to exit non-zero on failure)
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1 || echo "BACKUP FAILED" | mail -s "Backup failure on $(hostname)" ops@example.com
flock — prevent overlapping runs
If a cron job takes longer than its interval, two instances can run simultaneously — corrupting databases, doubling load, or causing other race conditions. flock uses a lock file to prevent this.
# Wrap any cron command with flock to prevent overlapping runs
# -n = non-blocking (exit immediately if lock is held, rather than waiting)
0 * * * * flock -n /tmp/myjob.lock /usr/local/bin/myjob.sh
# With a lock file in a more appropriate location
0 * * * * flock -n /var/lock/backup.lock /usr/local/bin/backup.sh
# Longer command with explicit exit code on lock failure
0 * * * * flock -n /tmp/db-cleanup.lock /usr/local/bin/db-cleanup.sh \
|| echo "db-cleanup already running, skipping" | logger -t cron
How it works
- The first run acquires the lock file
- If a second run starts before the first finishes,
flock -nexits immediately (no error, no duplicate run) - When the first run completes, the lock is released automatically
- The lock file itself is empty — only its existence and lock state matter
# Without -n: wait for the lock (use when you want queued execution, not skipping)
0 * * * * flock /tmp/myjob.lock /usr/local/bin/myjob.sh
Using -n (non-blocking) is usually correct for cron — you want to skip the run if the previous one hasn't finished, not queue another one behind it.
cron.allow / cron.deny
These two files control which users can use crontab. The logic is similar to SSH's AllowUsers / DenyUsers.
| File exists | Effect |
|---|---|
/etc/cron.allow exists | Only users listed in the file can use crontab. Everyone else is denied. |
/etc/cron.deny exists | Users listed in the file are denied. Everyone else can use crontab. |
| Both files exist | cron.allow takes precedence. cron.deny is ignored. |
| Neither file exists | Only root can use crontab (most restrictive default on some distros) or all users can (distro-dependent). |
# /etc/cron.allow — only alice and bob can create crontabs
alice
bob
# (one username per line, no wildcards)
# /etc/cron.deny — block a specific user from crontab
tempuser
# Check who can use crontab (by looking at which file exists)
ls -la /etc/cron.allow /etc/cron.deny 2>/dev/null
Root is always allowed to use crontab regardless of these files. System cron jobs in /etc/cron.d/, /etc/cron.daily/ etc. are also unaffected — these files only control crontab -e access for regular users.
CRON_TZ — per-job timezones
By default cron evaluates schedules in the system's local timezone. On fleets spanning regions, or when compliance requires a job to run at a specific UTC offset, set CRON_TZ in the crontab before the jobs that should use it. The variable applies to every job below it until another CRON_TZ is set.
# /etc/crontab or crontab -e
CRON_TZ=UTC
0 0 * * * root /usr/local/bin/rotate-utc-logs.sh # midnight UTC
CRON_TZ=Europe/London
30 2 * * * root /usr/local/bin/uk-billing-close.sh # 02:30 London wall clock
CRON_TZ=America/New_York
0 9 * * 1-5 alice /usr/local/bin/ny-market-open.sh # 09:00 NYC, weekdays
CRON_TZ is supported by Vixie cron (RHEL's cronie 1.4.4+ and Debian's cron). If your distro ships bcron or fcron, test before relying on it — not all cron implementations honour the variable. systemd timers sidestep the problem entirely with OnCalendar=Mon *-*-* 09:00:00 America/New_York.
DST caveats
Daylight Saving Time transitions create two failure modes for jobs scheduled by wall-clock time in a DST-observing zone:
- Spring-forward gap. In most zones the clock jumps from 02:00 directly to 03:00 on the DST-start date. A job scheduled at
30 2 * * *is never executed on that day — the minute 02:30 simply does not exist. - Fall-back overlap. In autumn the clock rewinds from 03:00 back to 02:00. A job at
30 2 * * *can run twice — once in DST, then again an hour later in standard time — because the same wall-clock minute occurs twice.
Mitigations, in order of preference:
- Schedule outside the DST transition window. Jobs at
30 4 * * *or later are immune on every common zone. - Use
CRON_TZ=UTCfor everything that does not need to align with local business hours — UTC has no DST. - Switch to systemd timers. systemd is DST-aware:
OnCalendar=*-*-* 02:30:00fires exactly once on any day, with the skipped day simply producing no invocation (orPersistent=truepicking it up after the gap). - Add idempotency in the job itself (e.g. a "ran within the last 23h" guard file) so a double fire during fall-back is harmless.
@reboot pitfalls
@reboot is not a general "run at startup" hook. Cron starts early in the boot sequence, before many filesystems are mounted and before user sessions exist. A @reboot job can easily run before NFS/autofs mounts come up, before your data volume's LUKS unlock, or before network interfaces finish DHCP — and then fail silently, with no retry.
Two specific traps:
- User crontabs need lingering. Under systemd, a regular user's cron jobs run via the user's login session. If the user is not logged in and
loginctl enable-linger <user>is not set,@rebootin their personal crontab will not execute because no user manager exists to drive it. Root and/etc/cron.d/jobs are unaffected. - Delayed filesystems / network.
@reboot /opt/app/start.shmay fire while/opt/appis still an empty mountpoint or while/home(on NFS) has not arrived yet. The script sees "file not found" and exits. Cron never retries.
For anything more complex than "touch a pidfile", prefer a systemd service with proper After=/Requires= dependencies, or a systemd timer with OnBootSec=2min that gives the rest of the boot time to settle:
# /etc/systemd/system/app-startup.service — the systemd equivalent of @reboot
[Unit]
Description=Run app start-up tasks after the system is settled
After=network-online.target remote-fs.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/app/bin/start.sh
User=appuser
[Install]
WantedBy=multi-user.target
Quick audit for @reboot landmines on a host: sudo grep -rE '^@reboot' /etc/crontab /etc/cron.d /var/spool/cron 2>/dev/null. For each hit, ask: does this still need cron, or does it belong in a systemd unit with real dependencies?