Replacing Your Cron Jobs With systemd Timers
Replace cron jobs with systemd timers: create service and timer units, validate OnCalendar schedules, enable catch-up runs, and inspect logs and failures.
To replace a cron job with a systemd timer, you write two unit files: a .service that runs the script and a .timer that says when. Then you enable the timer with systemctl enable --now.
If you have ever opened /var/log/backup.log at 9 a.m. and been unable to tell whether last night’s run worked, you already know why people move off cron. This guide moves one real crontab line to a timer:
30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Each section replaces one part of that line. The schedule becomes OnCalendar=, the redirect becomes the journal, and “did it run?” becomes systemctl status. For background on cron itself, see how to automate cron jobs. The commands work on any current systemd-based distribution.
Key Takeaways
- A systemd timer is a
.timerunit whose[Timer]section decides when a matching.serviceruns. The service defines what runs, and the timer is the unit you enable. - The service a timer triggers needs no
[Install]section. The timer attaches itself to boot throughWantedBy=timers.target. systemd-analyze calendarchecks that anOnCalendar=expression parses and prints the next time it will fire.Persistent=trueruns a job that was missed while the machine was powered off as soon as the timer is activated again.- systemd timers have no built-in equivalent of cron’s
MAILTO. For failure alerts, useOnFailure=.
What Cron Can’t Do
Cron starts commands on a schedule and does little else. The gaps below are the usual reasons to move a job:
- No logging beyond mailed output. You either redirect output to a file by hand or rely on
MAILTO. Timer jobs write to the journal automatically (see Reading the Logs). - No dependencies between jobs. Timer jobs are ordinary units, so they can depend on other systemd units.
- Nothing runs if the machine was off at the scheduled time.
Persistent=truecatches up on the missed run (see the Persistent section). - No easy way to check last night’s result.
systemctl statusshows the last run and its exit code.
Build a systemd Timer End to End
A working timer is two files in /etc/systemd/system/, plus one command: sudo systemctl enable --now backup.timer. Start with the script.
#!/usr/bin/env bash
# /usr/local/bin/backup.sh
set -euo pipefail
SRC=/srv/app/data
DEST=/var/backups/app
tar -czf "$DEST/app-$(date +%F).tar.gz" -C "$SRC" .
find "$DEST" -name 'app-*.tar.gz' -mtime +14 -delete
set -euo pipefail makes the script exit non-zero as soon as any command fails, and that exit code is what systemd records. Make the script executable and create the destination directory. If the permission bits are unfamiliar, the Linux file permissions guide explains them.
sudo chmod +x /usr/local/bin/backup.sh
sudo install -d -o deploy -m 750 /var/backups/app
Next, the service, /etc/systemd/system/backup.service:
[Unit]
Description=Nightly tarball of /srv/app/data
[Service]
Type=oneshot
User=deploy
ExecStart=/usr/local/bin/backup.sh
Type=oneshot in systemd.service(5) suits scripts that run to completion. systemd only treats the unit as started once the script has exited. Unless you add RemainAfterExit=, the unit then goes straight back to inactive and never shows as “active”. Replace deploy with an existing non-root account that can read SRC and write DEST. Without User=, the job runs as root. The service has no [Install] section and no reference to the timer. The Arch Wiki’s timer page explains why: the timer is the unit you enable, and it activates the service with the matching name.
Now the timer, /etc/systemd/system/backup.timer:
[Unit]
Description=Run backup.service daily at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
timers.target is a special target that systemd uses at boot to bring up all the timers meant to be running. Reload systemd, run the job once by hand, then schedule it:
sudo systemctl daemon-reload
sudo systemctl start backup.service # test run now; returns when the script exits
systemctl status backup.service # check the result
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
Because every timed job is an ordinary service, you can run it on demand with systemctl start backup.service and debug it without waiting for 02:30. On its own, enable only creates the symlinks. The --now switch in systemctl(1) also starts the timer. list-timers accepts a unit pattern, and --all includes inactive timers. Between runs the service shows as inactive. That is normal.
OnCalendar vs the Five-Field Cron Line
Run systemd-analyze calendar on any OnCalendar= expression before it goes into a timer file. It confirms that the expression parses and prints the next time it will fire. The grammar in systemd.time(7) is DayOfWeek Year-Month-Day Hour:Minute:Second. Any date or time field can be * to match everything, a comma-separated list of values, or a range written with ... You can drop the weekday entirely. If you keep it, use English day names such as Mon or Monday, not *.
Common cron schedules map to OnCalendar= expressions as shown below. The Linux cron cheatsheet covers the cron side of this table:
| Schedule | cron | OnCalendar |
|---|---|---|
| Daily at 02:30 | 30 2 * * * | *-*-* 02:30:00 |
| Weekdays at 22:30 | 30 22 * * 1-5 | Mon..Fri 22:30 |
| Sundays at 03:00 | 0 3 * * 0 | Sun *-*-* 03:00:00 |
| 1st of the month, midnight | 0 0 1 * * | *-*-01 00:00:00 |
| Every 15 minutes | */15 * * * * | *:00/15 |
The cron line 30 22 * * 1-5 becomes OnCalendar=Mon..Fri 22:30, because cron’s weekday numbers 1 to 5 are Monday to Friday. The /15 suffix means “repeat every 15 units”. Validate an expression with systemd-analyze calendar:
systemd-analyze calendar "Mon..Fri 22:30" --iterations=3
Example output (the dates are placeholders):
Original form: Mon..Fri 22:30
Normalized form: Mon..Fri *-*-* 22:30:00
Next elapse: Mon YYYY-MM-DD 22:30:00 CEST
(in UTC): Mon YYYY-MM-DD 20:30:00 UTC
From now: 5h 12min left
Iteration #2: Tue YYYY-MM-DD 22:30:00 CEST
Iteration #3: Wed YYYY-MM-DD 22:30:00 CEST
The normalized line shows the short form expanded into the full grammar. The (in UTC) line only appears when local time isn’t UTC. Releases older than systemd 253 label the extra runs Iter. #2 instead of Iteration #2. --iterations= requires systemd 242 or later.
One precision caveat: a timer’s AccuracySec defaults to one minute, so a 02:30:00 job isn’t guaranteed to start on that exact second. If you need the tightest timing systemd offers, add AccuracySec=1us to the [Timer] section.
Persistent=true: The Laptop Feature
With Persistent=true, a timer that missed its scheduled run because the machine was powered off starts its service as soon as the timer is activated again. That usually means the next boot. Plain cron has no equivalent. If the laptop is shut down at 02:30, cron skips the job. The separate anacron tool exists to catch up on missed jobs (on RHEL-family systems it ships with cronie), but it only schedules in whole days, not at a time of day. systemd.timer(5) records when each persistent timer last fired. Persistent= has no effect on timers without OnCalendar=, and the catch-up run still waits for any RandomizedDelaySec= delay.
Monotonic timers such as OnBootSec= and OnUnitActiveSec= behave differently. They stop counting while the system is suspended unless WakeSystem= is set. Use OnCalendar= plus Persistent=true for jobs tied to the time of day.
Reading the Logs and Last-Run Status
To see whether last night’s job succeeded, run systemctl status backup.service. It shows when the service last ran, whether it succeeded or failed, and the process’s exit code. journalctl(1) shows everything the script printed:
systemctl status backup.service
journalctl -u backup.service --since yesterday
journalctl -u backup.service --since "-2h"
--since accepts yesterday, today, absolute timestamps, and relative offsets prefixed with -. Note that yesterday means midnight at the start of yesterday, so the output also includes today’s entries.
It’s worth seeing a failed systemd timer job once on purpose. Edit the script so it reads SRC=/srv/app/missing, then run it:
sudo systemctl start backup.service
systemctl status backup.service
journalctl -u backup.service -n 20
tar fails, set -e makes the script exit non-zero, and the unit ends up in the failed state with that exit code in its status. The journal holds the tar error message itself. Restore SRC when you’re done.
When Is Cron Still Fine?
Cron is still fine, and simpler, for a single job with no dependencies. A job is one line in a crontab, cron is available almost everywhere, and a single nightly job with no dependencies runs perfectly well on it. Timers cost you two files and a few systemctl commands. They also have no built-in equivalent of MAILTO. To get a notification when a job fails, point the service’s OnFailure= directive at a unit that sends the alert.
Conclusion
Switching from cron to systemd timers gives you three things plain cron can’t provide: a job you can run and inspect on demand, output that lands in the journal without redirects, and catch-up runs after downtime. Pick the crontab job you most often worry about. Write its service and timer, validate the schedule with systemd-analyze calendar, test it with systemctl start, and delete the crontab line once systemctl status reports a successful scheduled run.
FAQs
Can I run a systemd timer as a regular user without root?
Yes. Put the .service and .timer files in ~/.config/systemd/user/ and manage them with systemctl --user, for example systemctl --user enable --now backup.timer. By default the user's systemd instance stops after that user's last login session closes, so the timers stop firing too. Run sudo loginctl enable-linger followed by the username to start the user instance at boot and keep it running without a session.
What is the difference between AccuracySec and RandomizedDelaySec?
AccuracySec gives systemd room to batch timer events and wake the CPU less often. The timer can fire at any point from the scheduled time until AccuracySec later, and that window is 1 minute by default. RandomizedDelaySec works against batching. It pushes each run back by a random amount up to the value you set (0 by default), so timers on the same schedule spread out instead of firing together. If you set both, the random delay is applied before systemd lines the event up with others.
How do I schedule a one-off job with systemd without writing unit files?
Use systemd-run with a timer option, for example sudo systemd-run --on-calendar='Mon 14:00' /usr/bin/cleanup.sh. This creates a transient timer and service pair. Only the timer starts right away, and the service runs when the timer elapses. Flags such as --on-active= and --on-boot= create monotonic transient timers. Transient units do not survive a reboot, so recurring jobs still belong in unit files.
Does the timer file have to share a name with the service it triggers?
No, but it is the default. A timer activates the service with the same name minus the suffix, so backup.timer starts backup.service. To trigger a differently named unit, set Unit= in the [Timer] section, for example Unit=nightly-backup.service. The value cannot be another .timer unit. Even so, systemd.timer(5) advises giving the timer and the unit it starts the same base name.