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:
- systemd.timer(5) — every timer option, including accuracy and persistence
- systemd.time(7) — the full calendar and timespan syntax
- ArchWiki: systemd/Timers — worked examples and common patterns