The two-file pattern
For a job, you write two files in /etc/systemd/system/:
# /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly database backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=postgres
Group=postgres
ExecStart=/usr/local/bin/db-backup.sh
Nice=10
IOSchedulingClass=idle
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run nightly database backup
[Timer]
OnCalendar=*-*-* 03:17:00
RandomizedDelaySec=15min
Persistent=true
Unit=db-backup.service
[Install]
WantedBy=timers.target
Enable + start the timer (not the service):
sudo systemctl daemon-reload
sudo systemctl enable --now db-backup.timer
# Verify
systemctl list-timers db-backup.timer
sudo systemctl status db-backup.timer
sudo journalctl -u db-backup.service -n 100
OnCalendar: the schedule syntax
More expressive than cron's 5-field syntax, fully introspectable:
OnCalendar=hourly # every hour at :00
OnCalendar=daily # every day at 00:00
OnCalendar=weekly # Monday at 00:00
OnCalendar=monthly # 1st of the month at 00:00
OnCalendar=Mon-Fri *-*-* 09:00:00 # weekdays at 9am
OnCalendar=*-*-* 02:30:00 # daily at 02:30
OnCalendar=*-*-1,15 04:00:00 # 1st & 15th at 04:00
OnCalendar=Sat *-*-* 06:00:00 # Saturdays at 6am
OnCalendar=*-01-01 00:00:00 # New Year only
OnCalendar=*-*-* 00/4:00:00 # every 4 hours starting midnight
# Multiple OnCalendar lines OR together
OnCalendar=Mon-Fri *-*-* 09:00:00
OnCalendar=Mon-Fri *-*-* 14:00:00 # twice a day, weekdays only
Validate any expression:
systemd-analyze calendar 'Mon-Fri *-*-* 09:00:00'
# Normalized form: Mon..Fri *-*-* 09:00:00
# Next elapse: Mon 2026-05-25 09:00:00 EDT
The features cron doesn't have
RandomizedDelaySec: stagger fleet-wide jobs
OnCalendar=daily
RandomizedDelaySec=30min
Every host runs the job within 30 minutes of midnight, but the exact time is randomized per-host (deterministic on the hostname, so it's stable across reboots). If you have 100 servers all running "send nightly stats to the analytics endpoint," cron has 100 hosts hitting the endpoint at 00:00:00; systemd timers spread them across the window.
Persistent=true: catch up on missed runs
OnCalendar=daily
Persistent=true
If the system was off when the job was scheduled (laptop closed; VM paused), the timer fires once on next boot. cron just misses; anacron is the historical workaround. systemd has it built in.
Dependency chains
# Timer triggers a service that depends on another being done
[Service]
ExecStart=/usr/local/bin/run-report.sh
After=db-backup.service
Requires=db-backup.service
Combined with WantedBy=db-backup.service on a follow-up unit, you get "when backup finishes, run report → when report finishes, send email." The unit machinery, not cron + script-glue.
Resource limits + sandboxing
[Service]
ExecStart=/usr/local/bin/heavy-job.sh
# CPU + IO + memory caps
CPUWeight=20
IOWeight=20
MemoryMax=1G
# Sandboxing
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictNamespaces=true
ReadWritePaths=/var/lib/myapp /var/log/myapp
Per-job sandboxing is a one-liner. cron jobs run with whatever the user's permissions are; tightening them requires extra mechanisms.
The journal: the killer feature
# Logs of the most recent run
sudo journalctl -u db-backup.service -n 200
# Follow live
sudo journalctl -u db-backup.service -f
# Only failed runs
sudo journalctl -u db-backup.service -p err
# Across all the units triggered by a specific timer (history)
sudo journalctl --since "1 week ago" -u db-backup.service
cron emails output to root@localhost; most setups MTA-less servers silently discard it. systemd timers log everything to the journal, indexed by unit, searchable. Failed runs are visible.
Inspect what's scheduled
systemctl list-timers --all
# Shows: NEXT LEFT LAST PASSED UNIT ACTIVATES
Compare to cron's "you have to cat the user crontab plus check /etc/cron.d/, /etc/cron.daily/, etc." discovery story. list-timers is the one place.
Per-user timers
For user-owned jobs that don't need root:
mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/backup-photos.service
nano ~/.config/systemd/user/backup-photos.timer
systemctl --user daemon-reload
systemctl --user enable --now backup-photos.timer
systemctl --user list-timers
For user timers to fire when the user isn't logged in, enable lingering:
sudo loginctl enable-linger amir
Templates: one definition, many instances
For "back up every database listed in a config":
# /etc/systemd/system/db-backup@.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/db-backup.sh %i
# /etc/systemd/system/db-backup@.timer
[Timer]
OnCalendar=daily
RandomizedDelaySec=30min
Unit=db-backup@%i.service
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now db-backup@app1.timer
sudo systemctl enable --now db-backup@app2.timer
sudo systemctl enable --now db-backup@app3.timer
systemctl list-timers 'db-backup@*'
One unit definition, N instances; %i in the unit is the part after @.
OnBootSec / OnUnitActiveSec: relative timers
[Timer]
OnBootSec=5min # fire 5 minutes after system boot
OnUnitActiveSec=1h # then every hour after the previous run finished
Unit=keep-alive.service
Useful for "run a poll, sleep an hour, run again" without writing a daemon. Combine with RemainAfterExit=false on the service to allow the timer to keep firing.
The migration from cron
- For each cron entry, write a
.service+.timerpair. - Translate the cron schedule to OnCalendar (
systemd-analyze calendarvalidates). - Enable the timer, disable the cron entry.
- Verify with
systemctl list-timers. - The journal replaces "did this job actually run last night" anxiety.
When cron is still simpler
- For ad-hoc per-user "run this once a week" jobs that don't need any of the above features —
crontab -eis one line; the systemd-timer version is two files plus enable + daemon-reload. - On non-systemd distros (Alpine without OpenRC-elogind, very minimal containers) — cron is universal.
- For embedded / busybox systems where systemd isn't present.
For any modern Linux server, the timer-based approach is the right default; cron remains as a familiar fallback, not as a recommendation.