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
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 to1minfor 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
- You install
foo.timerandfoo.service(same basename by default). - You enable and start the timer unit, not the service.
- When the timer elapses, systemd starts
foo.service(unless it is already active). - Timer units automatically get a
Before=dependency on the service they activate. - Calendar timers (
OnCalendar=) also order aftertime-set.targetandtime-sync.targetso they do not fire before the clock is sane. - Enable with
WantedBy=timers.targetso 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
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
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
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'
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
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'
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)):
- Apply
RandomizedDelaySec=first. - 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
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
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
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
Force a run without waiting for midnight:
sudo systemctl start lyra-daily-ping.service
journalctl -u lyra-daily-ping.service -n 20 --no-pager
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
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
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
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
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-
oneshotservice 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
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
-
Service already active — elapse is ignored; no second instance. Do not use
RemainAfterExit=yesoneshots for repetitive timers. -
Wrong basename / missing
Unit=— timer elapses, nothing useful starts. Keep names paired. -
Enabled the service instead of the timer — job ran once (or at boot via
[Install]), schedule never armed. Enable*.timer. -
Calendar fire before clock sync — missing RTC + no
time-wait-syncyields surprising “missed” or early runs. Order aftertime-sync.targetis automatic forOnCalendar=, but the sync service must exist and run. -
Timezone surprises — bare
09:00is local time. PinUTCin the expression when fleets span regions. -
Suspend — calendar events catch up on resume (once). Monotonic timers pause unless
WakeSystem=changes the clock basis. -
Coalesced accuracy — “why did it run at 00:37 instead of 00:00?” Because
AccuracySec=defaults to a minute and may be raised (for example1honfstrim.timer). Tighten only when you need to. -
Leaving Persistent= state behind — uninstall without
systemctl clean --what=statecan 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
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
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-lingerfor 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)