How to monitor a systemd timer (list-timers lies to you)
Here’s the check almost everyone runs to see whether a systemd timer is working:
systemctl list-timers
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-07-21 03:00:00 UTC 12h left Mon 2026-07-20 03:00:00 UTC 9h ago backup.timer backup.service
Next run scheduled, last run this morning, right on time. Looks healthy.
That output is compatible with backup.service having failed every single night
for the past month.
Two units, and you’re watching the wrong one
A systemd timer is always a pair. backup.timer decides when.
backup.service does the work. They’re separate units with separate state.
list-timers reports on the timer. The LAST column means “the last time this
timer fired its service” — not “the last time the job worked.” The timer’s job
is to pull the trigger. It did that. It has no opinion on what happened next.
So the service can start, exit 1 immediately, and land in failed state, and
the timer will still show a healthy LAST and a cheerful NEXT. It’ll do it
again tomorrow. The output above never changes.
The unit that actually knows is the service:
systemctl status backup.service
systemctl is-failed backup.service
Or, for everything at once — the command worth knowing:
systemctl --failed
Run that on a box you haven’t looked at in a while. It’s often educational.
Why nobody notices
Because failed units are perfectly quiet. A failed unit sits there in red,
forever, and systemd’s response is to write a line to the journal and move on.
Nothing emails you. Nothing pages you. The next timer cycle fires regardless, so
there’s no cascading breakage to trip over.
You have to go looking, and going looking requires already suspecting.
Wire up OnFailure
systemd has a built-in hook for this and it’s underused. In the service unit, not the timer:
[Unit]
Description=Nightly backup
OnFailure=alert@%n.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
When backup.service fails, systemd starts alert@backup.service.service.
%n passes the failed unit’s name through, so one template unit covers every
job on the box:
# /etc/systemd/system/alert@.service
[Unit]
Description=Alert for %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/send-alert.sh %i
That closes the loop on failure. It does nothing for the case that actually hurts.
OnFailure can’t fire if nothing ran
OnFailure needs a failure to react to. It’s silent when:
- Someone ran
systemctl disable --now backup.timerduring an incident. - The unit file was removed by a config management run.
- The box was off, and
Persistent=truewasn’t set, so the missed run was simply skipped. - The whole machine is gone.
In every one of those, nothing failed. Nothing ran. systemctl --failed is
clean, OnFailure has nothing to hook, and the job is dead.
For that you need the inverse check — something outside the box expecting to
hear from it. ExecStartPost is the right place, because for Type=oneshot it
only runs if ExecStart succeeded:
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
ExecStartPost=/usr/bin/curl -fsS --retry 3 --max-time 10 https://api.didmyjobrun.com/ping/YOUR-UUID
Backup fails, no ping. Timer disabled, no ping. Server never boots, no ping. All four failure modes above collapse into one signal: the ping didn’t arrive.
One caveat, and it’s the same trap as
the Kubernetes version of this post —
a failing ExecStartPost marks the whole unit failed, even though the real work
succeeded. If your job isn’t safe to be marked failed, guard it:
ExecStartPost=-/usr/bin/curl -fsS --max-time 10 https://api.didmyjobrun.com/ping/YOUR-UUID
The leading - tells systemd to ignore the exit code.
The short version
list-timerstells you the timer fired, never whether the job worked.systemctl --failedis the check you actually want, and nothing runs it for you.OnFailure=covers jobs that ran and broke.- A heartbeat covers jobs that never ran — the ones
OnFailurestructurally cannot see.
That last category is the one Did My Job Run watches: add one line to
ExecStartPost, and the silence becomes the alert.