DEV Community

Cover image for I built a local dead-man's-switch for cron jobs (no server, no signup)
benjamin
benjamin

Posted on

I built a local dead-man's-switch for cron jobs (no server, no signup)

Cron has a cruel design flaw: it tells you nothing when a job stops running.

A job that exits non-zero? Silent. A job that never fires because the host was
asleep? Silent. A backup script that's been dying at 2 a.m. for three days
straight? Completely silent — right up until the morning you actually need that
backup and discover the last good copy is from Tuesday.

The failure mode isn't "the job errored." It's the absence of success, and
absence is exactly the thing a logs-and-exit-codes mental model can't catch.

The usual fix, and why it bugged me

The classic answer is a heartbeat monitor: your job pings a URL when it finishes,
and a watchdog yells if the ping doesn't arrive on time. healthchecks.io,
Cronitor, Dead Man's Snitch — all good, all battle-tested.

But for a box of personal cron jobs and a few self-hosted boxes, they felt like
overkill:

  • I had to create an account.
  • My job names and timing metadata went to a third party.
  • It was one more SaaS dashboard to log into and forget about.

I wanted the heartbeat idea without the hosted part. So I built deadcron
a single CLI, zero dependencies, no server, no signup. State lives in a JSON file
under ~/.deadcron. Nothing leaves your machine unless you explicitly point it at
a webhook or SMTP server.

How it works

Three moving parts:

  1. Each run checks in. Wrap your command and deadcron records a "ping" on exit 0 (and the exit code on failure):
   # before:
   0 2 * * *  /opt/backup.sh

   # after:
   0 2 * * *  deadcron run backup --every 1d --grace 1h -- /opt/backup.sh
Enter fullscreen mode Exit fullscreen mode
  1. A job declares its expected rhythm with --every (plus an optional
    --grace). If the time since the last successful check-in exceeds
    every + grace, the job is overdue.

  2. A watchdog runs check on its own schedule. You add it to cron once:

   deadcron install --every 5m
Enter fullscreen mode Exit fullscreen mode

That's it. Now if backup doesn't succeed within a day (+1h slack), the next
check tick notices the silence and alerts you.

$ deadcron status
● backup    ok                every 1d   last: 3h ago
● sync      OVERDUE by 2h     every 1h   last: 5h ago
● report    FAILED (exit 1)   every 1w   last: 2d ago
Enter fullscreen mode Exit fullscreen mode

check exits non-zero when anything is overdue or failed, so it also drops
straight into CI or any other scheduler:

deadcron check            # exit 1 if unhealthy
deadcron check --json     # machine-readable
Enter fullscreen mode Exit fullscreen mode

Alerts, but only where you want them

check fires every channel you've turned on — and they all run locally:

deadcron config enable macos                 # native notification
deadcron config set-webhook https://hooks.slack.com/services/XXX
deadcron config set-email --to me@you.com --sendmail
deadcron config test                         # send a sample through all of them
Enter fullscreen mode Exit fullscreen mode
Channel How
terminal writes to stderr (great for CI / piping)
macOS native banner via osascript
webhook POST JSON {event, checkedAt, jobs} → Slack/Discord/your endpoint
email local sendmail, or direct SMTP (TLS 465 / STARTTLS, optional auth)

Repeat alerts are throttled (default: once per hour per job) so a long outage
doesn't turn into a notification flood.

Install

It ships on both registries, because half my cron jobs are Node and half are
Python:

npx deadcron            # Node — zero deps
pip install deadcron    # Python — pure stdlib
Enter fullscreen mode Exit fullscreen mode

Both implementations share the exact same ~/.deadcron state format, so you can
mix them on one machine and they won't step on each other.

A few design notes

  • No daemon. deadcron isn't a long-running process — it's just a CLI plus a state file. The "watchdog" is literally deadcron check invoked by your own cron. Less to crash, less to babysit.
  • run over ping. You can call deadcron ping <name> at the end of a script, but wrapping with run also captures non-zero exit codes, so a job that fires on time but crashes is still flagged.
  • grace matters. A job that runs "every hour" never runs at exactly one hour. Grace is the slack that keeps borderline timing from crying wolf.

Try it / break it

Code, issues, and the full README:

It's MIT, it's tiny, and I'd genuinely like to know where it falls over. If
you've got a cron job you'd be sad to lose silently, point deadcron at it and
tell me what's missing.

What do you use today to know your scheduled jobs are actually running?

Top comments (0)