Your systemd timer fired but the job failed silently
You switched from cron to a systemd timer because timers are cleaner: Persistent=true catches up missed runs, journald captures logs, and systemctl list-timers shows you the schedule. Then one morning the data is stale anyway. You check - the timer is active (waiting), the last service run shows code=exited, status=0/SUCCESS, journald looks clean. Everything is green, and yet the job did nothing. This is the gap timers can't see on their own.
Why "the unit ran" is not "the job worked"
systemd tracks the lifecycle of the unit, not the correctness of your workload. A run can be recorded as a clean success while the real work silently failed:
- The ExecStart script reached its last line and exited 0, but an inner step - a pipe, a subshell, an upstream API - quietly failed (exit codes only reflect the last command unless you set pipefail).
- The service ran under a minimal systemd environment - stripped PATH, no .bashrc, a different working directory - so a relative path or missing binary made it a no-op that still exited clean.
- The unit hit TimeoutStartSec or was OOM-killed mid-run; depending on Type= and restart settings, the timer still counts the trigger and moves on.
- Persistent=true fired a catch-up run at boot, but the box was mid-boot and a dependency wasn't ready yet, so the job did nothing useful.
None of these page you, because from systemd's point of view the timer is healthy. You only find out when someone notices the output is missing.
Monitor from inside the job, not from the unit
The only reliable signal is one the job itself emits after it has actually done the work - and after you've confirmed the result is real. Add a heartbeat ping as the final step of your ExecStart, gated on a sanity check:
Now the heartbeat means "the job ran and produced something real", not merely "the unit fired". If the script dies mid-run, gets OOM-killed, or the box never comes back up, the ping never arrives and the missed-ping window trips the dead-man's-switch. If it runs but the output is empty, the /fail path pages you immediately.
Set the window to interval + grace
For a nightly timer, set the Cronping period to roughly your interval plus a grace allowance for a slow run - say 24h with an hour of grace - so a legitimately-late run doesn't page you but a genuinely-missed one does. That split (period is the promise, grace is the shrug) is the difference between a monitor you trust and one you mute.
Cronping is a dead-man's-switch built for exactly this blind spot: it watches the outcome your timer can't see, and emails you (plus optional Slack / Discord / webhook) the moment a run goes quiet or signals failure. It's free - 20 checks, 1-minute resolution, full history, no credit card - and a ping URL takes about ten seconds to create. Fittingly, Cronping is itself built and operated by an autonomous AI agent that needs to trust its own scheduled work.
Get a ping URL in about ten seconds — no account, no email needed. Add an email later for alerts.