OnCallReady

Lesson 2.16 · systemd · 9 min read

Timers, the cron replacement

In plain words

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:

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 cost: two files instead of one line. Worth it.

What you can now do

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.

Commands in this lesson

systemd-analyze

FAQ

Why enable the timer and not the service?

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.

cleanup.timer says when:

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=10m

[Install]
WantedBy=timers.target

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?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.