Cron has scheduled Linux jobs since 1975, but systemd timers offer features cron simply cannot match: integrated logging, dependency ordering, randomized jitter, persistent catch-up after downtime, calendar expressions with second precision, and per-job resource limits via cgroups. If you administer modern Linux, replacing cron with timers pays for itself the first time a job needs to retry, log structured output, or wait for the network.
Anatomy of a timer unit
A systemd timer is two files: a .service describing the work, and a .timer describing when. Both live in /etc/systemd/system/ for system-wide jobs or ~/.config/systemd/user/ for per-user jobs.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly database backup
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/backup-db.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
# /etc/systemd/system/backup.timer
[Unit]
Description=Run nightly database backup
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=15min
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
Enable and start with systemctl enable --now backup.timer. Check status with systemctl list-timers, which shows the next/previous run for every active timer.
Calendar expressions you will actually use
OnCalendar= accepts a powerful syntax. Common patterns:
OnCalendar=hourlyโ every hour on the hour.OnCalendar=*-*-* 03:00:00โ every day at 03:00 local time.OnCalendar=Mon..Fri *-*-* 09:00:00โ weekday mornings only.OnCalendar=*:0/15โ every 15 minutes.OnCalendar=monthlyโ first of the month at midnight.
Test any expression before deploying:
systemd-analyze calendar 'Mon..Fri *-*-* 09:00:00'
systemd-analyze calendar --iterations=5 weekly
Persistence and jitter
Two timer options eliminate the largest cron pain points:
- Persistent=true โ if the system was off when the timer should have fired, run it as soon as the system boots. No more "the laptop was sleeping, so today's backup never happened."
- RandomizedDelaySec=15min โ adds a per-host random delay. When 200 servers all run an apt-update timer, this avoids a thundering herd against your mirror.
Logging and observability
Every invocation is captured by the journal. To inspect:
journalctl -u backup.service --since '24 hours ago'
journalctl -u backup.service -p err -b # errors since last boot
systemctl status backup.timer # last/next, exit code
Cron jobs traditionally email output via local MTA โ easy to miss when nobody reads root@localhost. Timers integrate with rsyslog, journald, and any log shipper you already run.
Resource limits and isolation
Because the work runs as a transient service, you get the full systemd sandboxing arsenal for free:
[Service]
MemoryMax=512M
CPUQuota=20%
PrivateTmp=true
ProtectSystem=strict
NoNewPrivileges=true
ReadWritePaths=/var/backups
A runaway backup script can no longer OOM-kill your database; a compromised cron command can no longer write outside its declared paths.
Migrating from crontab
For each cron line, generate a service+timer pair. A simple translation table:
0 3 * * *โOnCalendar=*-*-* 03:00:00*/10 * * * *โOnCalendar=*:0/10@rebootโOnBootSec=1minin the timer.
Use systemd-run --on-calendar='*:0/5' --unit=test /usr/bin/date to prototype a one-off timer without writing files.
Best practices
- Always pair timers with
Type=oneshotservices unless the script daemonizes itself. - Set
AccuracySec=to give systemd permission to batch wake-ups (saves laptop battery, reduces I/O contention on busy hosts). - Use
OnUnitActiveSec=for "every N minutes after the last run finished" semantics. - Add
Wants=network-online.targetfor jobs that need DNS or remote endpoints. - Pin a sane
WorkingDirectory=and absoluteExecStart=path; do not rely on PATH.
Cron will keep working forever, but every new scheduled job you write should be a systemd timer. The boilerplate is two files, the operational payoff is observability, isolation, and reliability you cannot retrofit into crontab.