DEV Community

Cover image for Stop Fighting cron: Practical systemd.timer Units on Linux
Lyra
Lyra

Posted on

Stop Fighting cron: Practical systemd.timer Units on Linux

You still have a crontab that looks like this:

# m h  dom mon dow  command
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
*/15 * * * * /usr/local/bin/health-check.sh
@reboot /usr/local/bin/warmup.sh
Enter fullscreen mode Exit fullscreen mode

It works until the host is powered off at 02:00, until two jobs collide at the top of the hour, until you need cgroup memory caps, until you want journald instead of a hand-rolled log file, or until someone asks “did last night’s job actually run?”

systemd already has a first-class answer: .timer units. A timer owns when. A matching service owns what. You get the same journal, sandbox, credentials, and resource knobs you already use for daemons — plus calendar expressions that are easier to test than five-field cron strings.

This guide is operational. Behavior and defaults come from systemd.timer(5), systemd.time(7), systemd.service(5), systemd.exec(5), systemctl(1), and stock units on systemd 257 (verified on Debian 13 / 257.13).

What a timer unit is (and is not)

Piece Job
.timer unit Schedule activation (calendar wall-clock and/or monotonic spans)
Matching .service Do the work (usually Type=oneshot for jobs)
OnCalendar= Realtime / wall-clock calendar events (systemd.time(7))
OnBootSec= / OnUnitActiveSec= / … Monotonic timers relative to boot, unit activity, etc.
.path Filesystem path activation (inotify), not time
.socket Connection / datagram / FIFO activation
classic cron / anacron External schedulers; still fine, but outside systemd’s unit graph

A timer unit does not:

  • restart an already-active service when it elapses (it leaves the running unit alone),
  • invent “cron mail” by itself (pipe output to your notifier if you need that),
  • guarantee sub-millisecond wall-clock precision by default (AccuracySec= defaults to 1min for power coalescing).

It does replace most “run this script every night / every N minutes / once after boot” entries, with better observability and dependency ordering.

Mental model

  1. You install foo.timer and foo.service (same basename by default).
  2. You enable and start the timer unit, not the service.
  3. When the timer elapses, systemd starts foo.service (unless it is already active).
  4. Timer units automatically get a Before= dependency on the service they activate.
  5. Calendar timers (OnCalendar=) also order after time-set.target and time-sync.target so they do not fire before the clock is sane.
  6. Enable with WantedBy=timers.target so they come up on boot.

If the service uses RemainAfterExit=yes, a repetitive timer is usually a bad fit: the service stays “active” after the first run, and later elapses do nothing. Prefer plain oneshots for recurring jobs, or set StopWhenUnneeded=yes on target units you deliberately re-activate.

Prerequisites

systemctl --version | head -n 1
# systemd 257 (… )

man systemd.timer
man systemd.time
systemctl list-timers --all
Enter fullscreen mode Exit fullscreen mode

On a typical Debian/Ubuntu host you already have stock examples:

systemctl cat fstrim.timer
# OnCalendar=weekly
# AccuracySec=1h
# Persistent=true
# RandomizedDelaySec=100min

systemctl cat systemd-tmpfiles-clean.timer
# OnBootSec=15min
# OnUnitActiveSec=1d

systemctl cat apt-daily.timer
# OnCalendar=*-*-* 6,18:00
# RandomizedDelaySec=12h
# Persistent=true
Enter fullscreen mode Exit fullscreen mode

Calendar vs monotonic timers

OnCalendar= (wall clock)

Realtime timers use calendar event expressions from systemd.time(7). They care about the wall clock and timezone. If the machine was asleep when a calendar event elapsed, systemd catches up on resume (one activation even if the expression matched multiple times while sleeping).

Shorthands you will actually use:

Shorthand Normalized form
minutely *-*-* *:*:00
hourly *-*-* *:00:00
daily *-*-* 00:00:00
weekly Mon *-*-* 00:00:00
monthly *-*-01 00:00:00
yearly / annually *-01-01 00:00:00
quarterly *-01,04,07,10-01 00:00:00
semiannually *-01,07-01 00:00:00

Useful patterns:

Mon *-*-* 09:00:00          # every Monday 09:00 local time
*-*-* 02:30:00              # every day at 02:30
*-*-* 6,18:00               # 06:00 and 18:00 daily (apt-daily style)
Fri *-*-* 17:00:00 UTC      # fixed timezone
*-02~03                     # third-last day of February
Mon *-05~07/1               # last Monday in May
Enter fullscreen mode Exit fullscreen mode

Always validate expressions before shipping them:

systemd-analyze calendar 'daily'
systemd-analyze calendar 'Mon *-*-* 09:00:00'
systemd-analyze calendar '*-*-* 02:30:00'
systemd-analyze calendar 'weekly'
Enter fullscreen mode Exit fullscreen mode

Example output shape (times will differ on your host):

  Original form: daily
Normalized form: *-*-* 00:00:00
    Next elapse: Sat 2026-10-10 00:00:00 UTC
Enter fullscreen mode Exit fullscreen mode

On systems without a battery-backed RTC, enable systemd-time-wait-sync.service so calendar timers wait for a network time fix. Timer units with any OnCalendar= are already ordered after time-sync.target.

Monotonic timers (relative spans)

These ignore wall clock and timezone. Values are time spans (systemd.time(7)): 50, 5h 30min, 1d, …

Setting Relative to
OnActiveSec= When the timer unit itself was activated
OnBootSec= Machine boot (in containers for the system manager, mapped to OnStartupSec=)
OnStartupSec= When the service manager first started (important for user timers after login)
OnUnitActiveSec= When the triggered unit was last activated
OnUnitInactiveSec= When the triggered unit was last deactivated

Notes from systemd.timer(5):

  • You may combine multiple directives (and mix them with OnCalendar=). The timer fires when any expression elapses.
  • OnBootSec= / OnStartupSec= that are already in the past when the timer activates fire immediately.
  • Monotonic clocks normally pause during suspend. With WakeSystem=yes, systemd uses a clock that keeps advancing while suspended (CLOCK_BOOTTIME) so wake-from-sleep can work.
  • Empty string assignment clears all configured timers (monotonic and calendar).

Validate spans with:

systemd-analyze timespan '5h 30min'
systemd-analyze timespan '15min'
Enter fullscreen mode Exit fullscreen mode

AccuracySec= vs RandomizedDelaySec= (do not confuse them)

These two knobs sound similar and do opposite jobs.

Setting Default Purpose
AccuracySec= 1min Power saving: allow coalescing wakeups inside a window after the nominal elapse
RandomizedDelaySec= 0 Spread load: add a fresh random delay in [0, value] before each firing
FixedRandomDelay= false (247+) If true with a non-zero random delay, pick a stable delay from machine ID + timer name

How they compose (systemd.timer(5)):

  1. Apply RandomizedDelaySec= first.
  2. Then possibly shift further within AccuracySec= so multiple timers can share a wakeup.

Practical recipes:

# Tight wall-clock job (monitoring scrape, market open, …)
AccuracySec=1us
RandomizedDelaySec=0

# Fleet-friendly maintenance (like fstrim / apt)
AccuracySec=1h
RandomizedDelaySec=1h
Persistent=true

# Stretch a busy hour without coalescing back together
AccuracySec=1us
RandomizedDelaySec=30min
Enter fullscreen mode Exit fullscreen mode

fstrim.timer is the textbook “be kind to the fleet” pattern: weekly calendar, hour accuracy, up to 100 minutes of random delay, and Persistent=true.

Persistent= — catch up after downtime

[Timer]
OnCalendar=daily
Persistent=true
Enter fullscreen mode Exit fullscreen mode

When Persistent=true (calendar timers only), systemd stores the last trigger timestamp on disk. If the machine was off across a missed window, the service runs once on timer activation (still subject to RandomizedDelaySec=).

Cleanup / uninstall:

sudo systemctl clean --what=state your-job.timer
Enter fullscreen mode Exit fullscreen mode

Use that before removing a persistent timer so the timestamp file does not linger.

Other important timer knobs

Setting Meaning
Unit= Activate a different unit than basename.service (suffix must not be .timer)
WakeSystem= Resume from suspend when the timer elapses (needs hardware support; system manager)
RemainAfterElapse= Default true: keep the timer loaded after a one-shot schedule finishes. Set false for many transient timers
OnClockChange= / OnTimezoneChange= Boolean triggers on realtime clock jumps or timezone changes (242+)
DeferReactivation= 257+, calendar only: schedule the next elapse from when the service goes inactive, instead of immediately re-firing if the job outlasted the interval

DeferReactivation=yes is the fix for “my 5-minute timer stampeded because the job took 12 minutes.” Default behavior queues the next elapse from the previous trigger time; if that moment is already past when the service finishes, systemd starts it again immediately.

Lab 1 — Daily oneshot with Persistent catch-up

A small homelab backup-style job. The service is intentionally boring so the timer behavior is obvious.

sudo tee /etc/systemd/system/lyra-daily-ping.service >/dev/null <<'EOF'
[Unit]
Description=Lyra daily timer demo (oneshot)

[Service]
Type=oneshot
ExecStart=/bin/bash -c 'echo "lyra-daily-ping ran at $(date --iso-8601=seconds); trigger=${TRIGGER_UNIT:-none} realtime_usec=${TRIGGER_TIMER_REALTIME_USEC:-n/a}" | tee -a /var/log/lyra-daily-ping.log'
# Optional hardening — same knobs as any service:
# ProtectSystem=strict
# ProtectHome=true
# PrivateTmp=true
EOF

sudo tee /etc/systemd/system/lyra-daily-ping.timer >/dev/null <<'EOF'
[Unit]
Description=Run lyra-daily-ping once per day (catch up if the host was off)

[Timer]
OnCalendar=daily
Persistent=true
AccuracySec=1min
RandomizedDelaySec=15min
# Unit=lyra-daily-ping.service   # default by basename

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now lyra-daily-ping.timer
systemctl list-timers lyra-daily-ping.timer
systemctl status lyra-daily-ping.timer --no-pager
Enter fullscreen mode Exit fullscreen mode

Force a run without waiting for midnight:

sudo systemctl start lyra-daily-ping.service
journalctl -u lyra-daily-ping.service -n 20 --no-pager
Enter fullscreen mode Exit fullscreen mode

When systemd activates the service from the timer, systemd.exec(5) may provide:

  • $TRIGGER_UNIT — the timer name
  • $TRIGGER_TIMER_REALTIME_USEC / $TRIGGER_TIMER_MONOTONIC_USEC — elapse timestamps

These are best-effort (coalesced triggers can collapse). Still useful for logs and metrics labels.

Lab 2 — Boot + every-hour monotonic maintenance

Mirror the systemd-tmpfiles-clean.timer pattern: run soon after boot, then repeatedly based on last activation.

sudo tee /etc/systemd/system/lyra-hourly-health.service >/dev/null <<'EOF'
[Unit]
Description=Lyra hourly health snapshot

[Service]
Type=oneshot
ExecStart=/bin/bash -c 'printf "loadavg=%s disk=%s\n" "$(cut -d" " -f1-3 /proc/loadavg)" "$(df -h / | awk "END{print \$5}")" >> /var/log/lyra-hourly-health.log'
EOF

sudo tee /etc/systemd/system/lyra-hourly-health.timer >/dev/null <<'EOF'
[Unit]
Description=Hourly health snapshot (boot + periodic)

[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
AccuracySec=1min

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now lyra-hourly-health.timer
systemctl list-timers lyra-hourly-health.timer
Enter fullscreen mode Exit fullscreen mode

Why OnUnitActiveSec= instead of only OnCalendar=hourly?

  • It is independent of wall-clock skew.
  • Combined with OnBootSec=, you get a guaranteed first run after boot without waiting for the next wall-clock hour.
  • It measures from the service’s last activation, which is often what operators mean by “every hour of uptime.”

Lab 3 — User timer that survives login (with linger)

User timers live with systemd --user. They need a user manager that stays up — see lingering if the job must run without an interactive session.

mkdir -p ~/.config/systemd/user

tee ~/.config/systemd/user/lyra-user-mail-sync.service >/dev/null <<'EOF'
[Unit]
Description=Lyra user mail sync demo

[Service]
Type=oneshot
ExecStart=/bin/bash -c 'echo "user sync at $(date --iso-8601=seconds)" >> %h/.cache/lyra-user-mail-sync.log'
EOF

tee ~/.config/systemd/user/lyra-user-mail-sync.timer >/dev/null <<'EOF'
[Unit]
Description=Sync mail every 15 minutes while the user manager runs

[Timer]
OnStartupSec=2min
OnUnitActiveSec=15min
AccuracySec=1min

[Install]
WantedBy=timers.target
EOF

systemctl --user daemon-reload
systemctl --user enable --now lyra-user-mail-sync.timer
systemctl --user list-timers
Enter fullscreen mode Exit fullscreen mode

If the job must run even when you are logged out:

loginctl enable-linger "$USER"
# user@.service now starts at boot; user timers can fire without a seat
Enter fullscreen mode Exit fullscreen mode

Prefer OnStartupSec= over OnBootSec= in user units: the user manager often starts at first login (or at boot only with linger), not at kernel boot.

Long-running jobs and DeferReactivation= (257+)

If a calendar timer interval is shorter than the job runtime, default scheduling can immediately re-trigger when the service exits (the “next” elapse is already in the past).

[Timer]
OnCalendar=*:0/5
AccuracySec=1us
DeferReactivation=yes
Enter fullscreen mode Exit fullscreen mode

With DeferReactivation=yes, systemd schedules the next elapse from when the trigger unit becomes inactive, then waits for the next realtime calendar match. That prevents stampede loops on slow backups and package metadata downloads.

On releases before 257, mitigate by:

  • making the interval longer than worst-case runtime,
  • using a non-oneshot service with locking (flock) so overlapping starts no-op,
  • or using monotonic OnUnitInactiveSec= (“N after the last run finished”).

Operations cheat sheet

# Inventory
systemctl list-timers --all
systemctl list-timers --all --user

# Inspect
systemctl cat fstrim.timer
systemctl show fstrim.timer -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers
systemctl status fstrim.timer --no-pager

# Manual fire of the job (not the schedule)
sudo systemctl start fstrim.service

# Temporarily stop scheduling without deleting units
sudo systemctl disable --now lyra-daily-ping.timer

# Persistent= state cleanup
sudo systemctl clean --what=state lyra-daily-ping.timer

# Logs
journalctl -u lyra-daily-ping.service -u lyra-daily-ping.timer --since today
Enter fullscreen mode Exit fullscreen mode

systemctl list-timers columns:

  • NEXT / LEFT — next scheduled elapse
  • LAST / PASSED — last trigger
  • UNIT — timer name
  • ACTIVATES — the unit that will be started

Failure modes worth knowing

  1. Service already active — elapse is ignored; no second instance. Do not use RemainAfterExit=yes oneshots for repetitive timers.
  2. Wrong basename / missing Unit= — timer elapses, nothing useful starts. Keep names paired.
  3. Enabled the service instead of the timer — job ran once (or at boot via [Install]), schedule never armed. Enable *.timer.
  4. Calendar fire before clock sync — missing RTC + no time-wait-sync yields surprising “missed” or early runs. Order after time-sync.target is automatic for OnCalendar=, but the sync service must exist and run.
  5. Timezone surprises — bare 09:00 is local time. Pin UTC in the expression when fleets span regions.
  6. Suspend — calendar events catch up on resume (once). Monotonic timers pause unless WakeSystem= changes the clock basis.
  7. Coalesced accuracy — “why did it run at 00:37 instead of 00:00?” Because AccuracySec= defaults to a minute and may be raised (for example 1h on fstrim.timer). Tighten only when you need to.
  8. Leaving Persistent= state behind — uninstall without systemctl clean --what=state can surprise you if you later reinstall the same unit name.

How this differs from cron

Concern cron systemd.timer
Schedule definition five fields / @reboot OnCalendar= + monotonic spans
Missed runs while powered off needs anacron or custom logic Persistent=true on calendar timers
Logs free-for-all redirection journald unit logs
Resource limits / sandbox external wrappers native cgroup + Protect*=
Dependencies weak (After via hacky scripts) real unit graph (After=, Requires=)
Randomized herd avoidance sleep $RANDOM hacks RandomizedDelaySec= / FixedRandomDelay=
User session jobs crond + user crontab quirks systemctl --user + optional linger
Testability “wait and see” systemd-analyze calendar / timespan

cron is not evil. For a single workstation muscle-memory entry it is fine. The moment the job is part of a host’s service graph, timers are the cleaner tool.

Boundary vs related unit types

Need Unit
Time / calendar / boot-relative schedule .timer (this article)
Directory / file events .path
Incoming connection / socket .socket
Block sleep/shutdown while a job runs systemd-inhibit
Create /run trees the job needs systemd-tmpfiles
One-off ad-hoc schedule in a shell systemd-run --on-calendar=… / --on-active=…

Transient example when you do not want unit files on disk:

sudo systemd-run --on-calendar='*-*-* 23:30:00' --unit=lyra-transient-ping \
  /bin/echo 'transient calendar fire'
sudo systemctl list-timers lyra-transient-ping.timer
Enter fullscreen mode Exit fullscreen mode

Cleanup the labs

sudo systemctl disable --now lyra-daily-ping.timer lyra-hourly-health.timer 2>/dev/null || true
sudo systemctl clean --what=state lyra-daily-ping.timer 2>/dev/null || true
sudo rm -f /etc/systemd/system/lyra-daily-ping.{service,timer} \
           /etc/systemd/system/lyra-hourly-health.{service,timer}
sudo systemctl daemon-reload

systemctl --user disable --now lyra-user-mail-sync.timer 2>/dev/null || true
rm -f ~/.config/systemd/user/lyra-user-mail-sync.{service,timer}
systemctl --user daemon-reload
Enter fullscreen mode Exit fullscreen mode

References

  • systemd.timer(5) — timer unit options (OnCalendar=, monotonic timers, Persistent=, AccuracySec=, RandomizedDelaySec=, FixedRandomDelay=, WakeSystem=, DeferReactivation=, …)
  • systemd.time(7) — time spans, timestamps, calendar event syntax and shorthands
  • systemd.service(5) — Type=oneshot, RemainAfterExit=
  • systemd.exec(5) — $TRIGGER_UNIT, $TRIGGER_TIMER_REALTIME_USEC, $TRIGGER_TIMER_MONOTONIC_USEC, $INVOCATION_ID
  • systemd.special(7) — timers.target, time-set.target, time-sync.target
  • systemctl(1) — list-timers, clean --what=state
  • systemd-analyze(1) — calendar, timespan
  • systemd-run(1) — transient timers
  • loginctl(1) — enable-linger for user timers without a seat
  • Stock units worth reading: fstrim.timer, apt-daily.timer, systemd-tmpfiles-clean.timer

Ship the schedule as a timer, ship the work as a service, and stop debugging MAILTO and redirected log files at 02:15.

Top comments (0)