Think of an alarm clock and a chore. The alarm clock does not do the chore; it only rings, and whoever hears it does the chore. You set the alarm, not the chore. And if you were away on holiday when it should have rung, a clever clock rings once when you get back.
In systemd, heartbeat.timer is the alarm clock and heartbeat.service is the chore, matched by having the same name. You enable the timer, never the service. OnCalendar= is "ring at 9:00 on Mondays", OnUnitActiveSec=30s is "ring 30 seconds after the chore last started", Persistent=true is the catch-up after the holiday, and RandomizedDelaySec= stops every house in the street ringing at the same second.
Why this matters
Every night at 02:00 a cleanup job should run. For decades that was cron: one line in a file called a crontab. It works, but when the job fails, nobody knows: its output went nowhere, and there is no single place showing what runs when. systemd timers do the same scheduling with everything else from this chapter attached: logs in the journal, restart rules, one table of every schedule.
What you need to know already: 2.5 (writing and installing a unit, Type=oneshot, enable vs start).
A timer is two units
heartbeat.timer schedules heartbeat.service. Same basename, different suffix - that is how they find each other. (Use Unit= in [Timer] if you want a different name.)
# heartbeat.service
[Unit]
Description=Write a heartbeat line to the journal
[Service]
Type=oneshot
ExecStart=/usr/bin/echo heartbeat
# heartbeat.timer
[Unit]
Description=Run the heartbeat every 30 seconds
[Timer]
OnActiveSec=10s
OnUnitActiveSec=30s
[Install]
WantedBy=timers.target
The service says what to run (/usr/bin/echo just prints its arguments, so the line lands in the journal). The timer says when, in a [Timer] section. timers.target is the boot milestone that starts all enabled timers.
You enable the timer, never the service.systemctl enable --now heartbeat.timer. The service has no [Install] section at all - it is not meant to be enabled; the timer is what starts it.
When to fire
Two families: calendar times (a date and clock time, like an alarm clock) and relative times (so long after some event, like a kitchen timer).
OnCalendar= calendar time: "Mon *-*-* 09:00:00", daily, hourly,
"*-*-* *:0/15:00" (every 15 minutes)
OnBootSec= relative to boot
OnStartupSec= relative to systemd starting
OnActiveSec= relative to the TIMER being activated
OnUnitActiveSec= relative to the last time the SERVICE ran <- the repeater
OnUnitInactiveSec= relative to the last time it finished
A calendar expression reads DayOfWeek Year-Month-Day Hour:Minute:Second; * means "any", and 0/15 means "starting at 0, every 15". So Mon *-*-* 09:00:00 is "every Monday, any date, at 09:00:00".
Combine the relative ones: OnActiveSec=10s + OnUnitActiveSec=30s means "first run 10 seconds after the timer starts, then every 30 seconds after each run".
Two modifiers that matter in production:
Persistent=true - if the machine was off when the timer should have fired, run it once immediately at the next boot. (An old cron add-on, anacron, used to do this.) It only applies to OnCalendar= timers; the relative ones (OnBootSec= and friends) ignore it.
RandomizedDelaySec= - spread the firing over a random delay up to this long. Without it, a hundred machines with OnCalendar=daily all hit the same download server at exactly 00:00:00.
systemd-analyze calendar "Mon *-*-* 09:00:00" explains an expression and shows the next elapse (the next time it would fire). Use it before trusting anything clever.
Why timers beat cron
The job is a unit: it gets User=, dependencies, and everything this chapter adds later (resource limits, sandboxing, "run this other unit if it fails").
Output goes to the journal, tagged by unit - no more >> /tmp/log 2>&1 on every cron line, and no emails from cron that nobody reads.
systemctl list-timers shows every timer on the box in one table. Columns: NEXT (when it fires next), LEFT (how long until then), LAST (when it last fired), PASSED (how long ago), UNIT (the timer), ACTIVATES (the service it starts). There is no equivalent for cron.
Missed runs are handled (Persistent=), and overlapping runs are not possible · if the service is still running, the timer does not start a second copy.
The cost: two files instead of one line. Worth it.
What you can now do
write a timer + service pair and enable the right one
read and test a calendar expression
see every schedule on a box with list-timers
Why it helps
Scheduled jobs are everywhere in platform work: renewing certificates, cleaning up old logs, backups, database dumps. Cron jobs fail silently, can run on top of each other, and keep their output nowhere. Timers give you systemctl list-timers (when did it last run, when is the next run), a journal entry for every run, resource limits, and failure alerts.
Real moments: "did the backup run last night?" is answered in one command. A nightly job that took down a box because it overlapped with the previous run cannot happen with a timer. A hundred servers hammering one download server at midnight is fixed with one line of RandomizedDelaySec. And moving a hand-run chore onto a timer is exactly the toil reduction from Chapter 0.
The timer is the thing that should start at boot and wait; the service is the job it triggers. The service usually has no [Install] section at all. Enabling the timer (WantedBy=timers.target) makes the schedule survive reboots. If you enabled the service instead, it would run once at boot and never again. systemctl enable --now heartbeat.timer is the complete command.
What is the difference between OnUnitActiveSec and OnCalendar?
OnCalendar= follows the wall clock: "every day at 03:00", "Mon..Fri 09:00". OnUnitActiveSec= is relative: "30 seconds after the service last started", whatever the time of day, and the count pauses while the machine is off. Use calendar times for business schedules and relative timers for "every N minutes" jobs, usually with OnBootSec= or OnActiveSec= for the first run.
Can a timer job run twice at the same time?
No. A timer starts its service unit, and a unit that is already running cannot be started again; the extra trigger is simply ignored. With cron, a job that takes longer than its interval starts a second copy, and overlapping backups or cleanups are a classic outage. If you truly want parallel runs, a template service (2.28) with one instance per run is the explicit way.
How do I test an OnCalendar expression?
systemd-analyze calendar "Mon *-*-* 09:00:00" rewrites the expression in its normal form and prints the next time it will fire; --iterations=5 shows the next five. It catches typos and time-zone surprises. After enabling, systemctl list-timers shows NEXT and LEFT for the real timer, and systemctl start job.service runs the job right now for testing.
Should I move every cron job to a timer?
Not necessarily. Cron is fine for simple, low-stakes jobs and is still installed on Ubuntu. Timers are worth it when you want logs in the journal, resource limits, failure alerts with OnFailure= (2.28), no overlapping runs, catch-up after downtime and list-timers visibility. For anything a team depends on, like backups or certificate renewals, those features are worth the second file.
In an interview Junior
How would you schedule a script to run every day at 02:00 with systemd?
Two units with the same base name.
cleanup.service says what to run: [Service] with Type=oneshot and ExecStart=/usr/local/bin/cleanup.sh, and no [Install] section.
Then sudo systemctl daemon-reload && sudo systemctl enable --now cleanup.timer - you enable the timer, never the service. Persistent=true runs a missed run at the next boot if the box was off at 02:00; the random delay stops a whole fleet firing at the same second. Check the expression with systemd-analyze calendar '*-*-* 02:00:00', the schedule with systemctl list-timers, and the output with journalctl -u cleanup.service.
Also asked: Why use a systemd timer instead of a cron line? · What is the difference between OnCalendar= and OnUnitActiveSec=? · A nightly job did not run. Where do you look?