Cron works. A single line in a crontab schedules a command, and the machine runs it year after year with no daemon to babysit. Trouble appears at the edges of that simplicity. A job is still running when its next slot arrives, a reboot silently skips the nightly backup, a script fails and the only trace is an email nobody reads. systemd timers replace cron while turning those edges into explicit configuration: dependencies, overlap, missed runs, runtime limits, and logs each get a directive instead of an assumption.
This is a migration tutorial, not another survey of systemd features. For the wider 2025–2026 release picture — cgroup v1 removal, SysV init deprecation, persistent journal defaults — read the companion post systemd Deep Dive in 2026: What’s Changed. Everything below assumes a unit file pair you can write, validate, and test on a staging host before it schedules anything important.
What a timer buys you that a crontab cannot express
A cron line describes what to run and when. It says nothing about ordering, concurrency, timeouts, or resources, so operators bolt those on with shell wrappers and flock calls. The systemd unit model makes each concern a first-class directive, documented in systemd.timer(5):
- Ordering.
After=network-online.targetplusWants=network-online.targetensures the job starts once the network is usable, without asleep 30hack at the top of a script. - Overlap protection. systemd refuses to start a unit that is already activating or running. A slow job simply skips the overlapping trigger instead of stacking a second copy that races over the same files.
- Missed-run catch-up.
Persistent=truestores the last trigger time and fires the job once after downtime, so a reboot no longer erases a nightly run. - Bounded runtimes.
TimeoutStartSec=stops a job that hangs, replacing the “it eventually finished last month” mystery. - Resource ceilings.
CPUQuota=andMemoryMax=keep one runaway batch job from starving the services on the same host. - Journal logging. Every run’s stdout and stderr lands in the journal by unit name, which is far more reliable than
MAILTOplus a hand-rolled>>redirect.
Cron still earns its place for trivial one-line housekeeping that needs no ordering, no overlap guard, and no failure path. The migration is worth the effort for load-bearing jobs, not for every line in the file.
The unit pair, written out
A systemd timer is always two units: a .timer that carries the schedule and a same-named .service that carries the work. The naming link is what lets systemctl enable --now backup-job.timer activate the whole arrangement. Here is a nightly backup job migrated from a cron line, with the timer first:
# /etc/systemd/system/backup-job.timer
[Unit]
Description=Nightly backup timer
[Timer]
# Recurring calendar form = "every day at 02:30:00 server time"
OnCalendar=*-*-* 02:30:00
# Fire once shortly after boot if a scheduled run was missed while powered off
Persistent=true
# Spread start times so many hosts do not hit the storage backend together
RandomizedDelaySec=15min
[Install]
# Enable THIS unit, never the .service
WantedBy=timers.target
Two details matter more than the rest. WantedBy=timers.target belongs on the timer: enabling the .service would run the job at every boot rather than on schedule. Persistent=true catches up a single missed run — it does not replay every skipped interval, so a job that was down for three days fires once, not three times.
The paired service holds the execution policy. For the backup work itself, the Backup Strategies in 2026 guide covers the tooling and retention side:
# /etc/systemd/system/backup-job.service
[Unit]
Description=Nightly backup job (migrated from cron)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=backup
WorkingDirectory=/opt/app
# systemd starts with a minimal environment and sources no shell profile
Environment=PATH=/usr/local/bin:/usr/bin:/bin
# "-" makes a missing environment file non-fatal
EnvironmentFile=-/etc/default/backup
ExecStart=/usr/local/bin/backup.sh
TimeoutStartSec=3600
Type=oneshot is the right choice for a batch job: the unit is considered active until ExecStart exits, which is exactly the overlap guard described above. TimeoutStartSec=3600 caps a hung run at one hour. One subtlety is timing precision — systemd does not promise the exact OnCalendar minute. AccuracySec= defaults to one minute, so a 02:30 timer may start within that window, and RandomizedDelaySec extends it deliberately.
Translating cron schedules safely
Most cron expressions map cleanly onto OnCalendar, but a few translate into different behavior if you are not looking. The syntax rules live in systemd.time(7). Validate every expression with systemd-analyze calendar before you enable anything:
systemd-analyze calendar '*-*-* 02:30:00'
systemd-analyze calendar 'Mon *-*-* 03:00:00'
The common conversions:
| Cron | systemd OnCalendar | Watch out for |
|---|---|---|
0 2 * * * | *-*-* 02:00:00 | Straightforward |
*/15 * * * * | *-*-* *:00/15:00 | Date prefix is mandatory |
0 3 * * 1 | Mon *-*-* 03:00:00 | Weekday name replaces the number |
@daily | *-*-* 00:00:00 | Equivalent |
@weekly | Sun *-*-* 00:00:00 | systemd’s weekly shorthand means Monday, cron’s @weekly means Sunday |
@reboot | OnBootSec=5min | Not a calendar expression at all |
The trap that bites hardest is cron’s day-of-month / day-of-week rule. In cron, 0 0 1 * 1 runs when the day is the 1st or the day is Monday — an OR, which surprises almost everyone. systemd has no OR operator, so split that intent into two timers, one for the 1st and one for Mondays. The second trap is @reboot: there is no calendar equivalent, and OnBootSec=5min in the [Timer] section is the correct replacement.
Finally, timezone semantics differ. OnCalendar is evaluated in the timer’s timezone, so a UTC-pinned server behaves differently from one on local time when daylight saving shifts. On hosts that matter, set the intended timezone explicitly and check the DST boundary the way you check the schedule itself.
Least privilege without breaking the job
The first pass at hardening is the environment, because a migrated job that cannot find its binaries fails in confusing ways. Set User= and Group= to the account the cron job actually ran as — a common migration bug is letting a job that ran as a service account silently run as root. Set WorkingDirectory= if the script assumes a cwd, and set PATH explicitly since systemd loads no shell profile. If the job needs secrets or tunables, read them from EnvironmentFile=-/etc/default/<name> rather than exporting them in a wrapper script. The systemd.exec(5) manual lists the full directive set.
Sandboxing comes second, added gradually so a working job does not break overnight:
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=true
ReadWritePaths=/var/lib/backup
CPUQuota=50%
MemoryMax=2G
ProtectSystem=strict makes most of the filesystem read-only, so pair it with ReadWritePaths= for every directory the job legitimately writes. PrivateTmp=true gives the job its own /tmp, which breaks tools that share files through that path with other processes. Resource ceilings matter most on shared hosts — the Performance Tuning Linux Servers guide covers how those limits interact with the rest of the workload. Reproduce the exact environment with systemd-run before editing a live unit, and confirm the job still succeeds:
sudo systemd-run --property=User=backup --property=ProtectSystem=strict \
--property=ReadWritePaths=/var/lib/backup --property=CPUQuota=50% \
/usr/local/bin/backup.sh
Verify like an operator
Trust comes from observed runs, not from a successful enable. Work through this sequence on every migrated job.
Validate the unit files for syntax and missing binaries first. systemd-analyze verify loads the units and reports unknown directives and unresolved ExecStart paths, as described in systemd-analyze(1):
sudo systemd-analyze verify /etc/systemd/system/backup-job.service /etc/systemd/system/backup-job.timer
Then enable the timer and inspect the schedule systemd actually computed:
sudo systemctl daemon-reload
sudo systemctl enable --now backup-job.timer
systemctl list-timers --all
systemctl list-timers --all shows the next and last trigger times for every timer, which is the fastest way to catch a schedule that does not mean what you thought. Force one manual execution so failures surface while you are watching, rather than waiting for the clock:
sudo systemctl start backup-job.service
journalctl -u backup-job.service --since today
The journal entry for the service is the replacement for cron’s email notifications and log redirects — the run’s stdout, stderr, and exit status all live under journalctl -u. Finish with systemctl --failed to confirm nothing on the host is sitting in a failed state after the change.
Migration runbook
Convert load-bearing jobs first, one at a time, and never convert the whole crontab in a single pass. Start with a complete inventory, because cron schedules live in more places than most admins remember:
cat /etc/crontab
ls -la /etc/cron.d/
ls -la /etc/cron.{daily,weekly,monthly}/
for u in $(cut -f1 -d: /etc/passwd); do crontab -l -u "$u" 2>/dev/null; done
For each entry, comment out the cron line instead of deleting it, write the unit pair, enable the timer, and confirm one successful journal run. Compare systemctl list-timers --all against the inventory until the counts match. Retire the commented cron entries only after every migrated job has a recorded successful run. Per-user jobs follow the same shape under ~/.config/systemd/user/, controlled with systemctl --user; a user timer that must run while the user is logged out needs loginctl enable-linger <user>. Readers running container workloads get the same unit model for services in Declarative Containers with systemd and Podman Quadlet.
Hazards and rollback
The single most dangerous moment is running both schedulers for the same job. systemd’s overlap guard does nothing about a cron job firing next to a timer, and a non-idempotent job that copies or uploads data will corrupt its own destination when two copies race. Comment out the cron line in the same change that enables the timer, and never leave a job scheduled in both places.
Other hazards worth planning for:
Persistent=truestate. The last-trigger stamp lives under/var/lib/systemd/timers. Clear it withsystemctl clean --what=state <name>.timerbefore uninstalling a timer, or a later reinstall can fire an unexpected catch-up run.- Failure notification.
MAILTOhas a direct replacement — attach anOnFailure=unit to the service that raises an alert, so a failed run is not silent. - Timezone drift. Pin the intended timezone on servers that cross DST boundaries, and set
Persistent=trueonly for jobs where a late catch-up run is acceptable. A job that must stay inside a maintenance window should dropPersistent=truerather than fight the catch-up semantics.
Rollback is deliberately boring, which is the point of leaving the cron entries in place:
sudo systemctl disable --now backup-job.timer
# then uncomment the original crontab line and confirm it still runs
Disabling the timer stops future scheduling while leaving the units on disk for inspection. Uncommenting the cron entry restores the previous behavior immediately, so a migration that misbehaves in production can be reversed in one step without reconstructing the original command.
The payoff is a schedule you can reason about: the job’s user, environment, timeout, resource ceiling, and log location are all declared in the unit files instead of implied by the shell it happened to inherit. Migrate one job, watch its journal, and the timer model stops feeling like extra ceremony and starts feeling like the safety net cron never had.
Related areas
Related What I Do
These What I Do pages are matched from the subject matter of this article, creating a cleaner path from educational content to implementation work.
Continue reading
Related articles
Based on shared categories first, then the strongest overlap in tags.