On this page

cron has been scheduling jobs on Linux for decades and remains a perfectly good tool. But every systemd system already runs a second scheduler, one that logs every run to the journal and can re-run jobs the machine slept through. This guide is about that one.

Everything here was checked on Ubuntu 26.04 (systemd 259.5), Fedora 44 (systemd 259.8), and Arch (261.2). Timers behave identically on all three across those systemd versions.

Why a timer, when cron exists

A systemd timer is a unit file that says when. It always travels with a service unit that says what: foo.timer triggers foo.service, the service runs your script, and the journal records the whole thing.

The Automation chapter covers cron, and for a personal desktop job cron is often the shorter path. Timers earn their keep when you want the run logged in journalctl like every other systemd operation, when the machine might be off at fire time, or when the job must not run before the system clock is set. Nothing stops you from using both.

Anatomy of a timer pair

Two files in /etc/systemd/system/, same base name:

# /etc/systemd/system/foo.service
[Unit]
Description=Do the thing

[Service]
Type=oneshot
ExecStart=/usr/local/bin/foo.sh

# /etc/systemd/system/foo.timer
[Unit]
Description=Do the thing on a schedule

[Timer]
OnCalendar=daily

[Install]
WantedBy=timers.target

Type=oneshot means the service runs once and exits, right for a script. The pair matches up automatically because they share a base name: systemd looks for foo.service next to foo.timer without a Unit= line.

The pairing is strict. A bar.timer with no bar.service refuses to start with Refusing to start, unit bar.service to trigger not loaded. If you see that message, the fix is usually a typo in one of the names.

The OnCalendar format

OnCalendar=daily is a shorthand. The long form spells out seven fields: day-of-week, year-month-day, hour:minute:second. * means every value, .. spans a range, and a weekday name goes in front. The timer reads local system time, which on servers is often UTC.

Because the syntax is dense, make systemd-analyze calendar a habit: it parses a spec without running anything and shows when the next fire lands:

$ systemd-analyze calendar daily
  Original form: daily
Normalized form: *-*-* 00:00:00
    Next elapse: Wed 2026-09-02 00:00:00 UTC
       From now: 16h left

Your dates will differ, of course. The normalized form is the interesting part: it is how systemd understood you. A handful of specs worth knowing:

What you write Normalized form Fires
daily *-*-* 00:00:00 every day at midnight
weekly Mon *-*-* 00:00:00 every Monday at midnight
Mon..Fri *-*-* 06:30:00 (the same) weekdays at 06:30
*-*-01 04:00:00 (the same) first of the month at 04:00
Sat *-*-* 04:00:00 (the same) every Saturday at 04:00

A malformed spec is caught, with exit status 1, before it becomes a job that silently never runs:

$ systemd-analyze calendar nonsense
Failed to parse calendar specification 'nonsense': Invalid argument

Monotonic timers: counting instead of reading the clock

Everything above was a wall-clock timer: it reads the calendar. A monotonic timer counts from an event, not from the clock. The common options are OnActiveSec (this long after the timer starts) and OnUnitActiveSec (this long after the service last ran).

A heartbeat is the classic use. Ten seconds after the timer starts, and every ten seconds after each run:

# /etc/systemd/system/heartbeat.timer, plus the same
# [Unit] and [Install] sections as foo.timer above
[Timer]
OnActiveSec=10s
OnUnitActiveSec=10s
AccuracySec=1s

That AccuracySec=1s line matters more than it looks. By default systemd allows itself a minute of slack to batch your timer’s wakeup with other pending work, so a ten-second timer fires anywhere from ten to about sixty seconds late and you will waste an evening convinced it is broken. With accuracy set to one second, the fires landed about eleven seconds apart in testing. For daily jobs the default is fine.

Enabling and starting

Drop both files into /etc/systemd/system/ as root. A unit systemd has never seen is picked up immediately, no reload needed. Editing a file it already knows is different: systemd keeps the old version in memory until you run sudo systemctl daemon-reload, and warns you if you forget.

Then let systemd handle both halves of the job:

$ sudo systemctl enable --now foo.timer
Created symlink '/etc/systemd/system/timers.target.wants/foo.timer' → '/etc/systemd/system/foo.timer'.

enable makes the timer start at boot by dropping a symlink into timers.target.wants, and --now starts it in the same breath. Without --now you have armed the schedule for future boots but left it dormant right now, a classic “why is nothing happening” trap. You can see the symlink in /etc/systemd/system/timers.target.wants.

Two one-word checks confirm the state. systemctl is-enabled foo.timer answers enabled, and systemctl is-active foo.timer answers active. The undo buttons are sudo systemctl stop foo.timer (dormant until the next boot, if still enabled) and sudo systemctl disable foo.timer, which answers Removed '/etc/systemd/system/timers.target.wants/foo.timer'. After stopping, systemctl list-timers reports 0 timers listed., with the hint Pass --all to see loaded but inactive timers, too.

Everything here is system-wide, as root. Per-user timers (systemctl --user) deserve their own walkthrough.

Watching a timer fire

systemctl list-timers is the cockpit view: one row per timer, with when it last ran, when it fires next, and which service it triggers. The next fire time is the one to squint at when a job seems late. For one timer in detail, systemctl status foo.timer shows the next fire time.

One thing that looks alarming but is not: after a successful run, the paired foo.service shows as static in its Loaded line and inactive between runs. A oneshot service is only “active” while the script runs, which is seconds.

The real receipt lives in the journal, filtered by unit:

$ journalctl -u foo.service
Sep 01 07:26:18 linuz-ubuntu foo.sh[3406]: foo timer fired at 07:26:18

Whatever your script prints lands in the journal with a timestamp and a PID, which is the whole reason the logging argument for timers holds up. Do not worry if the “Starting” and “Finished” framing lines differ from a friend’s system. Their wording changed between releases. The services and logs chapter covers journalctl.

Catch-up runs and jitter

Persistent=true in the [Timer] section handles the machine-was-off case. Without it, a fire that lands while the computer is asleep or powered down is simply skipped. With it, systemd notes the last run in a stamp file under /var/lib/systemd/timers/ and runs the service as soon as it can after a missed fire. In testing, a timer with an old stamp file fired within about a second of being started. One blind spot: a brand-new timer whose OnCalendar is already in the past does not back-fill on first activation. There is no stamp file yet, so systemd has no missed run to catch up on. Catch-up needs history.

RandomizedDelaySec=30 does the opposite: it delays each fire by a random extra 0 to 30 seconds, redrawn every time. One machine gains nothing here, but a hundred servers told to run weekly would all hit the same package mirror at the same second. A few random seconds spread that load out.

One-off timers with systemd-run

systemd-run builds a transient timer and service for a job that runs once, at a chosen time, with no unit files to write:

$ sudo systemd-run --on-active=30min --unit=oneshot-demo -- /usr/local/bin/disk-report.sh
Running timer as unit: oneshot-demo.timer
Will run service as unit: oneshot-demo.service

The -- separator matters: everything after it is the command to run, arguments included, with nothing parsed as systemd options. In half an hour the script runs once and both units evaporate. A transient timer lingers in list-timers briefly after firing, gone well within a minute.

Capstone: a weekly scratch-dir cleanup

Say /tmp/scratch collects .log files from various tools, and you want anything older than a week gone every Saturday at 04:00, local time.

First, prove the file selection before anything deletes a byte. find with -mtime +7 matches files modified more than seven days ago (the filesystem chapter covers find in depth):

$ find /tmp/scratch -name '*.log' -mtime +7
/tmp/scratch/old.log

find prints matches by default, so that output is the dry run. When the list looks right, add -delete, which removes exactly those files. Both forms were tested on the same directory and selected the same single file, nothing else touched. Dry run first, always.

Now the script, saved as /usr/local/bin/scratch-cleanup.sh:

#!/bin/bash
find /tmp/scratch -name '*.log' -mtime +7 -delete
echo "scratch-cleanup ran at $(date +%T), removing logs older than 7 days"

The echo line exists so the journal shows proof of each run. If a script grows past a few lines, the bash scripting guide takes over.

The service half of the pair goes into /etc/systemd/system/:

# /etc/systemd/system/scratch-cleanup.service
[Unit]
Description=Clean old logs out of /tmp/scratch

[Service]
Type=oneshot
ExecStart=/usr/local/bin/scratch-cleanup.sh

A deliberate mistake worth learning from: run the service before making the script executable.

$ sudo systemctl start scratch-cleanup.service
Job for scratch-cleanup.service failed because the control process exited with error code.
See "systemctl status scratch-cleanup.service" and "journalctl -xeu scratch-cleanup.service" for details.

systemctl status says only that the service failed with an exit-code result. The journal names the actual crime:

Failed at step EXEC spawning /usr/local/bin/scratch-cleanup.sh: Permission denied

The script never ran at all: exit code 203 with an EXEC failure means systemd could not launch the file, and “Permission denied” almost always means a missing execute bit. The fix, then start again:

$ sudo chmod +x /usr/local/bin/scratch-cleanup.sh
$ sudo systemctl start scratch-cleanup.service

The journal now shows the run:

Sep 01 07:36:52 linuz-ubuntu scratch-cleanup.sh[3725]: scratch-cleanup ran at 07:36:52, removing logs older than 7 days

The timer half goes in next to it:

# /etc/systemd/system/scratch-cleanup.timer
[Unit]
Description=Weekly cleanup of /tmp/scratch

[Timer]
OnCalendar=Sat *-*-* 04:00:00
Persistent=true

[Install]
WantedBy=timers.target

OnCalendar=Sat *-*-* 04:00:00 parses to the next Saturday at 04:00 local time, and Persistent=true borrows the catch-up trick so a laptop asleep on Saturday still gets cleaned later. First activation has no stamp yet, so the first run happens on the first live Saturday.

Arm it, then test it the same day instead of waiting a week:

$ sudo systemctl enable --now scratch-cleanup.timer
$ sudo systemctl start scratch-cleanup.service

Starting the service by hand runs the script immediately and leaves the schedule untouched. Enable for the future, start for a proof.

Where you are now

The full loop: validate a spec with systemd-analyze calendar, write a .service/.timer pair, enable it with --now, watch it in list-timers and journalctl, and debug a failed start through the journal. Monotonic timers, catch-up runs, and transient one-offs cover the awkward cases cron makes you fake. When a timer misbehaves, the validator and the journal between them answer almost every question.

For the complete option reference, the official pages are the ones to keep open: