Linux

systemd Timers: A Practical Replacement for Cron Jobs

By Nawawi JamiliPublished 7 min read

I ran cron jobs for years before I took systemd timers seriously. Cron works, but it has problems that every sysadmin learns about through painful experience. Output goes to a mailbox nobody reads. A job that runs longer than its interval starts a second copy on top of the first. The environment is nearly empty. And if the machine was off at 02:30, the nightly backup simply does not happen. Systemd timers fix all four, at the cost of writing two small files instead of one line.

This post walks through a complete timer, checks the schedule syntax with systemd-analyze, and covers the options that make timers better than cron. Everything was tested on systemd 239 (the version in RHEL/AlmaLinux 8), without installing or enabling anything: the units were written to a scratch directory and checked with systemd-analyze verify.

Two units: the job and the schedule

A timer does not run commands itself. It activates another unit, normally a service with the same name. So db-backup.timer starts db-backup.service. The service describes what to run:

ini
# /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly database backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/db-backup.sh

Type=oneshot tells systemd the process is expected to run and exit, and the unit counts as active only while it runs. That one line removes cron's overlap problem. If the timer fires while the previous backup is still going, systemd sees the service is already active and does not start a second copy.

The timer describes when:

ini
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run db-backup.service every night
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.target

The [Install] section belongs in the timer, not the service. You enable the timer. The service has no install section because nothing should start it at boot. If the service needs a different name, add Unit=other.service under [Timer].

Check the files before installing them

systemd-analyze verify parses unit files from any path and reports problems without loading them into the running system manager. A clean pair prints nothing:

terminal
$ systemd-analyze verify db-backup.service db-backup.timer
$ echo $?
0

To see what it catches, I made a deliberately broken pair with two misspelt keys and a bare command name:

ini
# bad.timer
[Timer]
OnCalender=daily
Persistant=true
# bad.service
[Service]
Type=oneshot
ExecStart=db-backup.sh
terminal
$ systemd-analyze verify bad.timer
bad.timer:2: Unknown lvalue 'OnCalender' in section 'Timer'
bad.timer:3: Unknown lvalue 'Persistant' in section 'Timer'
bad.timer: Timer unit lacks value setting. Refusing.
bad.service:3: Executable "db-backup.sh" not found in path "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Unit bad.timer has a bad unit file setting.

(I shortened the file paths in the output.) A typo in a key is only a warning, which systemd otherwise logs once and ignores. So a misspelt Persistent would silently do nothing. Verify also checks that the ExecStart binary exists, which is why I pointed my test copy at a real script in the scratch directory. Use absolute paths in ExecStart. It removes any doubt about which binary runs.

Note: on this systemd 239 build, passing both broken units in a single verify call printed the errors and then crashed with a segmentation fault. Checking one file at a time worked fine, so if verify crashes, try that.

OnCalendar syntax

The calendar format is DayOfWeek Year-Month-Day Hour:Minute:Second, where any part can be *, a list (Sat,Sun), a range (Mon..Fri) or a repetition (0/15, every 15 starting at 0). Leave out the day of week and it means any day. Leave out the seconds and they default to :00. You never have to guess what an expression means, because systemd-analyze calendar normalises it and prints the next time it fires:

terminal
$ systemd-analyze calendar 'Mon..Fri 02:30'
  Original form: Mon..Fri 02:30
Normalized form: Mon..Fri *-*-* 02:30:00
    Next elapse: Mon 2026-10-19 02:30:00 +08
       (in UTC): Sun 2026-10-18 18:30:00 UTC
       From now: 1 day 3h left

Every 15 minutes, the cron */15 * * * *:

terminal
$ systemd-analyze calendar '*:0/15'
  Original form: *:0/15
Normalized form: *-*-* *:00/15:00
    Next elapse: Sat 2026-10-17 23:30:00 +08
       (in UTC): Sat 2026-10-17 15:30:00 UTC
       From now: 28s left

The first day of each month at 04:00:

terminal
$ systemd-analyze calendar '*-*-01 04:00'
  Original form: *-*-01 04:00
Normalized form: *-*-01 04:00:00
    Next elapse: Sun 2026-11-01 04:00:00 +08
       (in UTC): Sat 2026-10-31 20:00:00 UTC
       From now: 2 weeks 0 days left

The shorthands minutely, hourly, daily, weekly, monthly and yearly also work. daily normalises to *-*-* 00:00:00, so every daily timer on every machine fires at midnight unless you spread them out (see below). An invalid expression is rejected:

terminal
$ systemd-analyze calendar 'Mon 25:00'
Failed to parse calendar specification 'Mon 25:00': Invalid argument

You can append a time zone, such as OnCalendar=*-*-* 02:30 Asia/Kuala_Lumpur, which helps when servers run in UTC but the job must follow local business hours. Newer systemd releases also accept --iterations=N to show the next several elapse times. 239 does not have it.

Persistent: catching up on missed runs

With Persistent=true, systemd stores the time of the last run on disk (under /var/lib/systemd/timers/). When the timer starts again after boot and finds it should have fired while the machine was off, it runs the service once immediately. Cron, by contrast, just skips it. That is exactly what you want on a laptop or a VM that is shut down at night. It only applies to OnCalendar timers.

RandomizedDelaySec: do not start everything at once

RandomizedDelaySec=15min delays each run by a random amount between zero and 15 minutes. Imagine fifty servers that all run the same backup at 02:30 against the same storage, or all contact the same mirror at midnight. A random spread turns that sudden peak in load into a slow, even rise. The stock dnf-makecache.timer on this machine does the same with RandomizedDelaySec=60m.

A related setting is AccuracySec, which defaults to one minute: systemd may fire a timer up to a minute late so it can batch wake-ups. Set AccuracySec=1s only if a job genuinely needs to start on the second.

Installing, testing and listing

On a real server, the steps after copying both files into /etc/systemd/system/ are:

terminal
$ sudo systemctl daemon-reload
$ sudo systemctl start db-backup.service
$ sudo systemctl enable --now db-backup.timer

Starting the service by hand first is the habit I recommend most. It runs the job once, right now, with exactly the environment the timer will use, so you find permission or PATH problems at 3pm instead of at 02:30. Then enable --now starts the timer and makes it start on every boot.

systemctl list-timers shows every active timer, when it last ran and when it runs next. This is the read-only output from my machine:

terminal
$ systemctl list-timers --all
NEXT                         LEFT          LAST                         PASSED       UNIT                         ACTIVATES
Sat 2026-10-17 23:40:00 +08  6min left     Sat 2026-10-17 23:30:00 +08  3min 13s ago sysstat-collect.timer        sysstat-collect.service
Sun 2026-10-18 00:00:00 +08  26min left    Sat 2026-10-17 11:49:25 +08  11h ago      unbound-anchor.timer         unbound-anchor.service
Sun 2026-10-18 00:07:00 +08  33min left    n/a                          n/a          sysstat-summary.timer        sysstat-summary.service
Sun 2026-10-18 01:11:43 +08  1h 38min left Sat 2026-10-17 23:27:02 +08  6min ago     dnf-makecache.timer          dnf-makecache.service
Sun 2026-10-18 22:59:58 +08  23h left      Sat 2026-10-17 22:59:58 +08  33min ago    systemd-tmpfiles-clean.timer systemd-tmpfiles-clean.service

5 timers listed.

That one table is something cron never gave me: a single place to answer "did last night's job run?".

Logs with journalctl

Everything a service writes to standard output or standard error goes to the journal, tagged with the unit name. No more >> /var/log/backup.log 2>&1 on every crontab line, and no more cron mail. Query the service, not the timer:

terminal
$ journalctl -u dnf-makecache.service -n 3
-- Logs begin at Sat 2026-10-17 22:45:00 +08, end at Sat 2026-10-17 23:33:02 +08. --
Oct 17 23:27:50 web1 dnf[149987]: Metadata cache created.
Oct 17 23:27:50 web1 systemd[1]: dnf-makecache.service: Succeeded.
Oct 17 23:27:50 web1 systemd[1]: Started dnf makecache.

Useful variations: journalctl -u db-backup.service --since today, -f to follow a run live, and systemctl status db-backup.service, which shows the exit status of the last run along with the latest log lines. A non-zero exit puts the service in the failed state, which monitoring can pick up with systemctl --failed.

User timers

You do not need root. Put the same pair of files in ~/.config/systemd/user/ and add --user to every command:

ini
# ~/.config/systemd/user/photo-sync.service
[Unit]
Description=Sync photos to the NAS
[Service]
Type=oneshot
ExecStart=/usr/bin/rsync -a %h/Pictures/ /tmp/nas-photos/
# ~/.config/systemd/user/photo-sync.timer
[Unit]
Description=Sync photos every 30 minutes
[Timer]
OnCalendar=*:0/30
Persistent=true
[Install]
WantedBy=timers.target
terminal
$ systemd-analyze --user verify photo-sync.service photo-sync.timer
$ systemctl --user daemon-reload
$ systemctl --user enable --now photo-sync.timer
$ journalctl --user -u photo-sync.service

%h expands to your home directory. The limitation is that a user's systemd instance normally runs only while that user is logged in. For a timer that must run when you are not, ask an administrator to enable lingering once: sudo loginctl enable-linger username.

When I still use cron

For a one-line job on a machine I rarely change, cron is still quicker to write. For anything I care about, I use a timer. That includes backups, certificate renewals, report generation, or anything that must not overlap. The overlap protection, catch-up after downtime, central logs and list-timers are worth the extra file. The systemd.timer and systemd.time manual pages cover the remaining options, such as OnBootSec and OnUnitActiveSec for intervals measured from boot or from the last run.