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 by run-parts at 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:

  • PATH is usually just /usr/bin:/bin — so aws, kubectl, anything installed in /usr/local/bin or ~/.local/bin won't be found unless you use absolute paths or set PATH.
  • HOME is set, but no ~/.bashrc / ~/.profile is sourced.
  • No SSH_AGENT, no DBUS_SESSION_BUS_ADDRESS, no DISPLAY.
  • 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 .service unit defines what to run.
  • A .timer unit 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 with OnBootSec for "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 OnCalendar is 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 with journalctl -u.
  • Resource limits — Cron: none. systemd: full cgroup controls (memory, CPU, I/O).
  • Security sandboxing — Cron: none. systemd: extensive Protect*/Restrict*/SystemCallFilter directives.
  • Status visibility — Cron: read log files. systemd: systemctl list-timers, systemctl status with 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 logger for sane logging.
  • ✓ systemd timers are a .timer + .service pair.
  • OnCalendar= is expressive; verify with systemd-analyze calendar.
  • Persistent=true handles missed runs; RandomizedDelaySec= spreads load.
  • ✓ Resource limits via MemoryMax/CPUQuota and friends — cron can't do this.
  • ✓ Hardening via PrivateTmp, ProtectSystem, NoNewPrivileges, etc.
  • ✓ On modern Linux, prefer systemd timers unless portability forces cron.