cron & systemd Timers
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.