Cron Jobs vs systemd Timers
Both cron and systemd timers schedule jobs on Linux. Cron is the venerable POSIX-ish standard you'll find on virtually every Unix system. systemd timers are newer, integrate cleanly with the rest of systemd's machinery (logs, resource limits, sandboxing), and have largely supplanted cron on modern Linux distributions. In this guide we'll cover both, side by side, and give clear guidance on when to reach for each.
Real-World Scenario
You need to run a nightly backup at 02:30, with a memory cap.
Cron can run it. systemd timers can run it, log it cleanly to the journal, cap its RAM, run it under a sandbox, and re-trigger it if the machine was off at the scheduled time. By the end of this guide you'll have a feel for which option to pick — and how to write either one cleanly.
Cron Syntax
A crontab entry has five time fields followed by the command. man 5 crontab is the authoritative reference.
# ┌──────── 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 are both Sunday) # │ │ │ │ │ # * * * * * command to run # Every 5 minutes */5 * * * * /usr/local/bin/heartbeat # 02:30 every day 30 2 * * * /usr/local/bin/backup.sh # 09:00 every Monday 0 9 * * mon /usr/local/bin/weekly-report # First of every month at midnight 0 0 1 * * /usr/local/bin/monthly-rollup
Cron also accepts special strings as shortcuts:
@reboot run once at boot @yearly once a year (0 0 1 1 *) @monthly once a month (0 0 1 * *) @weekly once a week (0 0 * * 0) @daily once a day (0 0 * * *) @hourly once an hour (0 * * * *)
Where Cron Jobs Live
There are three common places to put cron entries. They behave subtly differently.
crontab -e— edits the current user's personal crontab in/var/spool/cron/crontabs/<user>(path varies). Entries run as that user. No user field in lines./etc/cron.d/<name>— system-wide drop-in files. Same syntax as crontabs but with an extra user field before the command. Recommended for packaged software because you can ship a single file./etc/cron.{hourly,daily,weekly,monthly}/— drop an executable script in here and it will be run byrun-partsat the corresponding interval. No fancy scheduling but very simple.
Example /etc/cron.d entry (note the user field)
# /etc/cron.d/site-backup # m h dom mon dow user command 30 2 * * * backup /usr/local/bin/backup.sh
anacron is a separate tool that runs jobs that were missed while the machine was off. On laptops/desktops, anacron typically owns the daily/weekly/monthly directories and ensures they still run after the machine wakes from sleep or boots up late.
Cron's Environment Quirks
This is where most cron jobs silently fail in production. Cron does not source your shell login files. The environment it provides is minimal:
PATHis usually just/usr/bin:/bin— soaws,kubectl, anything installed in/usr/local/binor~/.local/binwon't be found unless you use absolute paths or set PATH.HOMEis set, but no~/.bashrc/~/.profileis sourced.- No
SSH_AGENT, noDBUS_SESSION_BUS_ADDRESS, noDISPLAY. - Locale defaults to C, which can break tools that expect UTF-8 in filenames or output.
You can set environment variables at the top of a crontab:
SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=en_US.UTF-8 [email protected] 30 2 * * * /usr/local/bin/backup.sh
Logging Cron Jobs
By default, cron emails the output of the job to the local user — which usually just fills up /var/mail/<user> on a machine that has no working MTA. Three better options:
Logging options
# 1. Set MAILTO at the top of the crontab to ship output to a real address [email protected] # 2. Redirect stdout/stderr to a file (most common in practice) 30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1 # 3. Pipe to logger so it lands in syslog/journal 30 2 * * * /usr/local/bin/backup.sh 2>&1 | logger -t backup # Then read with: journalctl -t backup --since "1 hour ago"
Cron's own activity (which jobs ran, exit codes, mail spool errors) usually goes to /var/log/syslog on Debian/Ubuntu and /var/log/cron on RHEL/Rocky. View with journalctl -u cron (Debian/Ubuntu) or journalctl -u crond (RHEL/Rocky).
systemd Timers: the Basics
A systemd timer is a pair of unit files:
- A
.serviceunit defines what to run. - A
.timerunit defines when to run the service.
Place them either in /etc/systemd/system/ (system-wide, runs as root by default) or ~/.config/systemd/user/ (user-level, needs systemctl --user).
Example: nightly backup at 02:30
# /etc/systemd/system/backup.service [Unit] Description=Nightly backup of /srv to S3 Wants=network-online.target After=network-online.target [Service] Type=oneshot User=backup ExecStart=/usr/local/bin/backup.sh # Optional hardening (see "Sandboxing" below) # /etc/systemd/system/backup.timer [Unit] Description=Run nightly backup at 02:30 [Timer] OnCalendar=*-*-* 02:30:00 Persistent=true RandomizedDelaySec=5min Unit=backup.service [Install] WantedBy=timers.target
Enable and start the timer (not the service — the timer triggers the service):
sudo systemctl daemon-reload sudo systemctl enable --now backup.timer
OnCalendar Syntax
Calendar specs look similar to cron but are more expressive. The general form is:
DayOfWeek Year-Month-Day Hour:Minute:Second [Timezone]
Examples:
OnCalendar=*-*-* 02:30:00 # every day at 02:30 OnCalendar=Mon..Fri 09:00 # weekdays at 09:00 OnCalendar=*-*-1 00:00:00 # first of every month at midnight OnCalendar=hourly # shortcut for *-*-* *:00:00 OnCalendar=daily # shortcut for *-*-* 00:00:00 OnCalendar=*-*-* 0/4:00:00 # every 4 hours: 00:00, 04:00, 08:00, ... OnCalendar=Mon *-*-* 09:00 Europe/London # 09:00 London time, Mondays
Always verify with systemd-analyze calendar:
Sanity-check a calendar expression
systemd-analyze calendar 'Mon..Fri 09:00' # Example output: # Original form: Mon..Fri 09:00 # Normalized form: Mon..Fri *-*-* 09:00:00 # Next elapse: Mon 2026-05-25 09:00:00 BST # From now: 1 day 16h left
Non-Calendar Triggers
Timers can also fire relative to events:
OnBootSec=— run N seconds after boot. Useful for "first run on startup but not immediately."OnStartupSec=— run N seconds after systemd itself started.OnUnitActiveSec=— run N seconds after the unit last became active. Use withOnBootSecfor "every 10 minutes" style intervals.OnUnitInactiveSec=— run N seconds after the unit last became inactive (i.e. finished).
"Every 10 minutes after boot" pattern
[Timer] OnBootSec=2min OnUnitActiveSec=10min Unit=poller.service
Persistent=true is the "anacron-equivalent": if the system was off when a scheduled run was due, the timer fires as soon as the system comes up. Use it for daily/weekly maintenance jobs on machines that aren't 24/7.
RandomizedDelaySec= spreads out jobs that would otherwise stampede a shared resource at the same minute on many machines (e.g. RandomizedDelaySec=15min on a backup script across a fleet).
Viewing and Managing Timers
Day-to-day commands
# List all active timers and when they next fire systemctl list-timers # Include inactive ones too systemctl list-timers --all # Inspect one timer + its service systemctl status backup.timer systemctl status backup.service # View logs for the service the timer triggers journalctl -u backup.service journalctl -u backup.service --since "today" journalctl -u backup.service -f # follow live # Run the service manually (test path) sudo systemctl start backup.service # Disable sudo systemctl disable --now backup.timer
Resource Limits Cron Can't Do
This is where systemd timers pull ahead. The service unit can enforce limits that cron simply has no way to set.
Capping memory and CPU
# Inside backup.service [Service] section MemoryMax=512M # OOM-kill if exceeded MemoryHigh=384M # throttle once exceeded CPUQuota=50% # at most half a core TasksMax=64 # hard cap on processes IOWeight=50 # reduce I/O priority (1-1000; default 100) Nice=10 # lower CPU priority
Behind the scenes systemd uses cgroups (v2 on modern systems) to enforce these. The same controls would require cgcreate/cgexec plumbing if you tried to bolt them onto a cron job — and you'd still lose them on reboot.
Security Sandboxing
systemd ships a long list of Protect* and Private* directives that turn a service into a small jail with almost no code changes. The basics that should be on most non-trivial services:
Reasonable default hardening
[Service] # ... existing directives ... NoNewPrivileges=true # never gain extra privileges via setuid/setgid PrivateTmp=true # service gets its own /tmp and /var/tmp ProtectSystem=strict # whole filesystem becomes read-only except a few paths ProtectHome=true # /home, /root, /run/user become inaccessible ProtectKernelTunables=true # /proc/sys, /sys etc. become read-only ProtectKernelModules=true # block loading kernel modules ProtectControlGroups=true # block writes to /sys/fs/cgroup RestrictNamespaces=true LockPersonality=true MemoryDenyWriteExecute=true # no W^X violations (some JITs fail this — test first) RestrictRealtime=true SystemCallFilter=@system-service SystemCallErrorNumber=EPERM # If your service legitimately needs to write somewhere: ReadWritePaths=/var/lib/backup /var/log/backup
Use systemd-analyze security backup.service to grade your hardening; it gives a score from 0 (perfect) to 10 (no protection) and points out specific directives you could add.
Comparison at a Glance
- Schedule expressiveness — Cron is dense and familiar; systemd's
OnCalendaris more readable and supports timezones natively. Both can express the same set of times. - Catch-up on missed runs — Cron: needs anacron. systemd: built-in via
Persistent=true. - Jitter to avoid stampedes — Cron: roll your own with
sleep $((RANDOM % 300)). systemd:RandomizedDelaySec=. - Logging — Cron: per-job redirection and/or
logger. systemd: stdout/stderr captured to the journal automatically, queryable withjournalctl -u. - Resource limits — Cron: none. systemd: full cgroup controls (memory, CPU, I/O).
- Security sandboxing — Cron: none. systemd: extensive
Protect*/Restrict*/SystemCallFilterdirectives. - Status visibility — Cron: read log files. systemd:
systemctl list-timers,systemctl statuswith last run, last exit code, next run. - Portability — Cron runs on every Unix (Linux, BSD, macOS, AIX, Solaris). systemd timers are Linux + systemd only.
- Boilerplate — Cron: a one-line crontab entry. systemd: two unit files.
Guidance
Use systemd timers on modern Linux unless you need cross-platform/portable POSIX. The wins on logging, resource caps, and sandboxing easily justify the extra unit file in any environment that's already running systemd (which is the vast majority of production Linux as of the mid-2020s).
Stick with cron when:
- Targeting non-systemd platforms (FreeBSD, OpenBSD, Alpine with OpenRC, macOS).
- Shipping a script in a portable package that should "just work" on minimal containers.
- You need a quick one-off and don't want to write two unit files.
Use systemd timers when:
- You care about resource limits (memory, CPU, I/O).
- You need real logs (stdout to journal, exit codes, status).
- You want the job sandboxed.
- You need timezone-aware schedules or jittered fleet-wide jobs.
- You need a job to catch up after the machine was off (laptops, kiosks, occasional servers).
Quick Cheat Sheet
Side-by-side commands we keep handy
# --- cron --- crontab -e # edit current user's crontab crontab -l # list it sudo crontab -u root -e # edit root's crontab ls /etc/cron.d/ # packaged system jobs journalctl -u cron # cron daemon log (Debian/Ubuntu) journalctl -u crond # cron daemon log (RHEL/Rocky) # --- systemd timers --- systemctl list-timers --all # all timers, next/last run systemctl status NAME.timer # detailed status systemctl cat NAME.timer # show the unit file journalctl -u NAME.service # service output systemd-analyze calendar 'EXPR' # verify a calendar expression sudo systemctl edit NAME.service # add an override drop-in sudo systemctl daemon-reload # after editing any unit file
Summary
- ✓ Cron syntax: 5 time fields + command (or 6 with a user field in
/etc/cron.d/). - ✓ Watch out for cron's minimal environment — set PATH and use absolute paths.
- ✓ Redirect cron output to a file or
loggerfor sane logging. - ✓ systemd timers are a
.timer+.servicepair. - ✓
OnCalendar=is expressive; verify withsystemd-analyze calendar. - ✓
Persistent=truehandles missed runs;RandomizedDelaySec=spreads load. - ✓ Resource limits via
MemoryMax/CPUQuotaand friends — cron can't do this. - ✓ Hardening via
PrivateTmp,ProtectSystem,NoNewPrivileges, etc. - ✓ On modern Linux, prefer systemd timers unless portability forces cron.