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:
- Email-only delivery: the API response contains no URLs. Your ping URL and dashboard link arrive by email — which doubles as proof you own the address. No verification flow, no accounts, same guarantee.
-
Invisible Turnstile + honeypot on registration, plus strict origin enforcement
at the API Gateway — while the ping route is explicitly exempted from browser-only headers,
because machines don't send
X-Source. -
Config over headers: every URL that lands in a customer email is built from
deploy-time config, never from the request's
Originheader. A spoofed header must never become a phishing link in an email we signed. - Rate limits everywhere: per-IP registration caps in KV, per-email monitor caps in D1, and a 1-accepted-ping-per-minute throttle per monitor that turns a ping flood into a cheap 200 with zero database writes.
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:
- Minimum interval: 5 minutes. One-minute monitors are 12x the cost.
- 200 active monitors, max 50 "fast" ones (under an hour). Worst case: ~18k pings/day — 18% of the request and write budgets, leaving room for everything else.
- 30 registrations/day globally. Every registration is a welcome email, and the Resend quota is shared with audit reports and alerts.
- Alert cooldown per monitor. A job flapping every five minutes must not drain the day's email quota by breakfast. DOWN emails are throttled per monitor, and the recovery email is paired — suppressed alert, suppressed recovery. The dashboard still shows every transition.
- KV writes are the sneaky one: only 1,000/day on free. A heartbeat write on every 5-minute sweep would eat a third of it, so the heartbeat only writes on quarter-hour runs.
"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:
- Compute: Cloudflare Workers + Cron Triggers ($0)
- Database: D1, write-on-transition ($0)
- Cache & rate limits: Workers KV ($0)
- Email: Resend free tier ($0)
- Bot defense: Turnstile ($0)
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.
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