← Back to Blog
ENGINEERING DEEP DIVE

Building a dead-man's switch for cron jobs with Cloudflare Workers and D1

It started, like most good tools, with a lie.

My homelab runs on cron. Nightly restic backups to the NAS, certificate renewals, database dumps, an rsync job that mirrors twenty years of family photos off-site. All of it scheduled, all of it "handled." One evening I went looking for a file in the backup repository and noticed the latest snapshot was three weeks old.

The NAS had rebooted after a power blip. The mount point never came back. The backup script exited with an error — into a local mail spool that nobody, including me, has read since 2019. Cron did exactly what it was told. It just never told me.

"A failing cron job sends an error. A dead cron job sends nothing. It's the nothing that gets you."

That's the trap with cron monitoring: you can't rely on the job to report its own failure, because the worst failures are the ones where the job never runs at all. The machine is off. The container didn't start. Someone commented out the crontab line "temporarily" in March.

The fix is an old aviation idea: the dead-man's switch. Don't detect failure — detect the absence of success. The job pings a URL after every successful run. If the pings stop, something is wrong, and it doesn't matter what: you get an email.

Tools like healthchecks.io do this beautifully. But I had a Cloudflare Worker sitting right there from the domain audit engine build, a free tier I'd barely scratched, and a rule that StackAnchor runs at $0/mo. In the last post I promised Pulse would be uptime monitoring. Then my homelab reminded me what actually breaks first. So Pulse got repurposed — vibe coding means the roadmap loses to reality.

The Contract: One Line of Crontab

The entire user-facing API is a URL. You register a job, tell us how often it should run, and we email you a unique ping URL. You append one thing to your crontab:

# Ping fires ONLY if the backup succeeds (&& short-circuits)
0 2 * * * /usr/local/bin/backup.sh && curl -fsS --retry 3 \
  https://api.stackanchor.net/pulse/ping/<128-bit-id> > /dev/null

That && is doing the heavy lifting: the ping only fires when your job exits 0. Job crashes? No ping. Machine dies? No ping. Someone breaks the crontab? No ping. Every failure mode, including the silent ones, collapses into the same detectable signal — silence.

On the other side, a Worker on the 5-minute cron trigger runs one SQL query that asks the only question that matters:

SELECT id, name, email FROM cron_monitors
WHERE active = 1 AND status = 'UP'
  AND (last_ping + (period_minutes + grace_minutes) * 60000) < ?

Anything that comes back is overdue: mark it DOWN, email the owner, notify the admin Discord. When the pings resume, a recovery email goes out. Because only UP → DOWN transitions alert, each outage produces exactly one email — not one every five minutes until you fix it.

Security: The URL Is the Password

A ping endpoint has a strange security profile. It must be callable by curl from a crontab — no browser, no cookies, no CAPTCHA. So the URL itself is the credential: a 128-bit random hex ID, unguessable by construction, validated by regex before it ever touches the database. The dashboard gets a separate ID, so sharing your status page never leaks the ping secret.

Around that, the same defense-in-depth pipeline from the audit engine, adapted:

The Free-Tier Budget Is a Design Constraint

Here's the fun part. A cron monitor's cost is just 1/period: a 5-minute job pings 288 times a day; a daily job, once. Cloudflare's free tier gives you 100k Worker requests and 100k D1 writes per day. Resend gives you 100 emails a day. Those three numbers are the product spec:

"On the free tier, capacity planning isn't an ops task you do later. It's arithmetic you do in the same file as the feature."

The Ping That Lied

Mid-build, I hit a bug that perfectly rhymed with the reason this tool exists. Testing locally, I curled my shiny new ping URL: 200 OK. Refreshed the dashboard: waiting for first ping. The worker said OK. The database said nothing happened.

The culprit: a legacy catch-all route — the uptime badge endpoint — answered 200 to any unmatched GET. My ping had hit the worker with a stale path prefix, missed the ping route, and fallen into the catch-all, which cheerfully confirmed... nothing. A monitoring system whose own success signal could lie. The irony was not lost on me.

The fix was two-fold: tolerate the path prefix, and — more importantly — kill the catch-all. Unknown paths now return 404, which means curl -f in your crontab fails loudly on a misconfigured URL instead of pretending everything is fine.

"Never let an endpoint say OK when it did nothing. That's how you build a liar."

The Economics, Again

The bill of materials hasn't changed since the last post:

Total: $0/mo — for a service that would have saved three weeks of my family photos from living dangerously.

What's Next?

The uptime monitoring I promised last time is still coming — the same worker already does the checks. On the cron side: /start and /fail endpoints for measuring job duration, status badges, and per-monitor webhook alerts.

Meanwhile, my restic backups now carry their little && curl tail, and for the first time in years I trust the silence — because it's monitored.

FREE CRON MONITORING

Your Cron Jobs Are Unsupervised

Get a dead-man's switch for your scheduled jobs. One line of crontab, no credit card, no BS.

🛡 Create a Cron Monitor