Timers
Topic 44

Timers

systemdScheduling

A systemd timer is a unit that activates another unit on a schedule. It is the systemd-native replacement for cron: instead of a crontab line, you write a .timer file that says when, paired with a .service file that says what. The timer never runs your command directly — it starts the matching service, and the service is where the command, the user, the environment, and the resource limits live. The pairing is by name: backup.timer activates backup.service unless you override the target with Unit=.

The operational payoff is that scheduled jobs become first-class units. They show up in systemctl list-timers, their output and exit status land in the journal instead of an emailed stdout, they inherit cgroup accounting and sandboxing, and a job that overruns its interval will not stack a second copy on top of the first the way two overlapping cron runs do. The cost is verbosity: every job is now two files instead of one crontab line, and the timer syntax has its own calendar grammar you have to learn.

The Timer and Service Pair

A timer unit carries a [Timer] section; the work goes in a sibling service. You enable and start the timer, not the service — the service is started on demand when the timer fires. A minimal pair for a nightly backup looks like this on Debian or Ubuntu, with both files under /etc/systemd/system/.

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=backup
# /etc/systemd/system/backup.timer
[Unit]
Description=Run the nightly backup

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true

[Install]
WantedBy=timers.target

The service is Type=oneshot because it runs to completion and exits rather than staying resident — that is the right type for batch work, and it lets systemd treat the run as finished only when the script returns. There is no [Install] section in the service, because you never enable it yourself; the timer pulls it in. Enable the timer with systemctl enable --now backup.timer, and it both starts now and survives reboot through timers.target.

Calendar Timers with OnCalendar

Calendar timers fire at wall-clock times using OnCalendar=, which is the direct analogue of a cron schedule. The grammar is DayOfWeek Year-Month-Day Hour:Minute:Second, with * as a wildcard, comma lists, ranges with .., and / for repetition. There are also named shortcuts: daily, weekly, monthly, and hourly expand to fixed times.

OnCalendar expressionFires
*-*-* 02:30:00Every day at 02:30
Mon..Fri 09:00Weekdays at 09:00
*-*-* *:0/15Every 15 minutes, on the quarter-hour
Mon *-*-* 00:00:00Every Monday at midnight
*-*-01 04:00The first of every month at 04:00

Always validate an expression before you trust it. systemd-analyze calendar 'Mon..Fri 09:00' parses the string, normalizes it, and prints the next elapse time — add --iterations=N to see several — so you can confirm a job lands when you think it does instead of discovering at 02:30 that you wrote the wrong field. The same tool catches the classic mistake of a time that never matches.

Monotonic Timers and Boot-Relative Schedules

Not every job belongs on a wall clock. Monotonic timers fire relative to an event rather than a calendar date, using directives like OnBootSec= (time after boot), OnStartupSec= (time after systemd itself started), OnUnitActiveSec= (time after the paired unit last ran), and OnUnitInactiveSec=. These are how you express "fifteen minutes after boot, then every six hours" without pinning to specific clock times.

[Timer]
# run 15 min after boot, then every 6 h afterwards
OnBootSec=15min
OnUnitActiveSec=6h

Monotonic timers do not understand Persistent= — that directive only applies to OnCalendar timers, because "did I miss a run" only has meaning against a wall clock. Mixing the two in one timer is legal and occasionally useful, but the common pattern is to pick one model per job: calendar timers for "at 02:30 nightly", monotonic timers for "a while after boot, then periodically".

Catch-up, Jitter, and Accuracy

Persistent=true records the last time a calendar timer ran in /var/lib/systemd/timers/. If the machine was off or asleep when the timer should have fired, the job runs once at next boot to catch up. This is the systemd answer to anacron, and it is the directive laptops and intermittently-on servers need — without it, a backup scheduled for 02:30 simply never happens on a machine that is powered down overnight.

RandomizedDelaySec= spreads a timer's fire time randomly across a window, which matters when a fleet of hosts all run the same calendar timer and would otherwise hammer a backup target or package mirror at exactly 02:30. AccuracySec= controls how loosely systemd is allowed to coalesce timer wakeups — the default is one minute, which lets the kernel batch wakeups and save power; set it to 1s only when a job genuinely needs second-level precision, because tighter accuracy means more wakeups.

Inspecting and Debugging Timers

systemctl list-timers is the single most useful command here: it lists every active timer with its next elapse time, the time left, the last time it ran, and the unit it activates. systemctl list-timers --all includes inactive ones. To see why a specific job did or did not run, read the journal for the service, not the timer, since the service is where the command executed.

# what is scheduled and when it next fires
systemctl list-timers --all

# did the last run succeed? full output of the job
journalctl -u backup.service

# trigger the job by hand, ignoring the schedule
systemctl start backup.service

Starting the .service by hand runs the job immediately and is the correct way to test it — starting the .timer only arms the schedule, it does not run the work. On Red Hat and Fedora the commands are identical; the only divergence from Debian and Ubuntu is unit placement conventions and the surrounding package names, not the timer mechanism itself.

Timers vs cron vs at

systemd timers — recurring jobs that need logging, resource limits, dependency ordering, or catch-up after downtime. Choose them for anything you would otherwise babysit, and on any host you already manage with systemd units.

cron — one-line recurring jobs where portability and brevity win, and on systems without systemd. Still the fastest thing to write, but it has no overlap protection, no native logging beyond mailed stdout, and no missed-run catch-up unless you add anacron.

at — a single job at one future time ("run this once at 18:00"). Neither cron nor a timer is the right tool for a one-shot deferred command; at is.

Common Mistakes
  • Enabling the .service instead of the .timer. Enabling the service either fails (oneshot with no install target) or runs it once at boot and never again — the schedule lives in the timer, and the timer is what you enable --now.
  • Omitting Persistent=true on a calendar timer for a laptop or an overnight-off server. The job silently never runs because the machine was powered down when the timer should have fired, and nothing logs the miss.
  • Naming the timer and service differently without an explicit Unit=. nightly.timer looks for nightly.service; if your work is in backup.service the timer activates a unit that does not exist and fails quietly.
  • Editing a unit file and forgetting systemctl daemon-reload. systemd keeps running the old cached definition, so your schedule change appears to have no effect until the next reboot.
  • Rolling out one OnCalendar=02:30 timer across a whole fleet with no RandomizedDelaySec=. Every host fires at the same instant and overwhelms the shared backup target or mirror.
  • Setting AccuracySec=1s by reflex. Tight accuracy defeats wakeup coalescing and wastes power for jobs that do not care whether they run at 02:30:00 or 02:30:45.
  • Looking for failures in journalctl -u backup.timer. The timer log only shows arming and firing; the command's actual output and exit status are under the .service unit.
Best Practices
  • Validate every schedule with systemd-analyze calendar 'expression' before deploying — it prints the next elapse time (use --iterations=N for more) and catches expressions that never match.
  • Set Persistent=true on any calendar timer that must not skip a run, so a missed window is caught up at next boot.
  • Add RandomizedDelaySec= to any timer you deploy across more than one host, sized to spread load over minutes so the fleet does not fire in lockstep.
  • Keep the timer and service names matched so the implicit pairing works, and use Unit= explicitly only when you deliberately want one timer to drive a differently-named service.
  • Make the service Type=oneshot for batch jobs, and test by running systemctl start the.service directly before arming the timer.
  • Run systemctl daemon-reload after every unit-file edit, then confirm the new schedule with systemctl list-timers.
  • Sandbox scheduled jobs in the service with directives like User=, ProtectSystem=strict, and PrivateTmp=true — a timer-driven service gets the same hardening surface as any other unit, which a crontab line never had.
Comparable toolscron / anacron — the classic Unix scheduler; anacron adds the missed-run catch-up that Persistent=true gives a timerWindows Task Scheduler — registered tasks with triggers, conditions, and history, the closest model to timer+servicemacOS launchd — .plist agents and daemons with StartCalendarInterval and StartInterval keys

Knowledge Check

Why does a systemd timer use two unit files instead of one?

  • The .timer defines when to fire and the paired .service defines what to run, so the job inherits service features like logging, resource limits, and sandboxing
  • The .timer file holds the command to run and the paired .service file holds the schedule, deliberately mirroring the way a single crontab line is internally split
  • One file is for wall-clock calendar schedules and the second file is required separately for monotonic ones
  • systemd needs one separate unit file for each distinct user that the scheduled job will run as

A backup timer is set to OnCalendar=*-*-* 02:30 on a laptop that is powered off overnight. What does Persistent=true change?

  • If the machine was off at 02:30, the job runs once at next boot to catch up the missed run
  • It keeps the timer armed across reboots but does nothing about runs missed while powered off
  • It runs the job every boot in addition to the scheduled time
  • It converts the calendar timer into a monotonic OnBootSec timer

When would you reach for at rather than a systemd timer or cron?

  • For a single command to run once at one future time, with no recurrence
  • For a recurring job that also needs missed-run catch-up after the host has been down
  • For a fleet-wide job that must spread its load out with a randomized startup delay
  • For any job whose stdout and exit status must be captured in the journal

A scheduled job is not producing output. Where do you look first?

  • journalctl -u job.service — the service is where the command ran and its exit status is recorded
  • journalctl -u job.timer — the timer's own log is where the command's stdout and exit status are recorded
  • systemctl status job.timer, which prints the full command output inline alongside the schedule
  • The mailbox of the user who owns the job, where systemd mails the captured stdout by default

Why add RandomizedDelaySec= to a calendar timer deployed across many hosts?

  • It spreads each host's fire time across a window so the fleet does not hit a shared backup target or mirror simultaneously
  • It tightens overall timer accuracy so that every host in the fleet fires at the exact same instant
  • It is a strictly required prerequisite for Persistent=true to be able to catch up any of the runs that were missed across reboots
  • It retries the failed job at random intervals afterward until it finally succeeds

You got correct