Case Study
Building Cronbell: a dead-man’s-switch monitor for cron jobs
August 2026 · updated October 2026 · 8 min read
Most monitoring answers “is this thing throwing errors?” That’s the wrong question for a huge class of backend work — cron jobs, scheduled ETL runs, nightly backups, batch report generators. Those jobs don’t usually crash loudly. They just quietly stop running: a server reboots and the cron entry never comes back, a dependency changes and the script silently no-ops, a deploy overwrites the schedule. Nothing throws an exception anywhere, because nothing runs at all — and that’s exactly the kind of failure that goes unnoticed until someone asks where last week’s report is.
I built Cronbell — called PulseCheck until I renamed it in October — to catch that class of failure specifically. It’s live as a free demo, not a toy project, and the code is at github.com/shane-Coder/cronbell.
The idea: a dead man’s switch
The model borrows directly from dead-man’s-switch design in safety-critical systems: instead of waiting for a failure signal, you wait for the absence of a success signal. Every monitor in Cronbell gets a unique ping URL. You add one line to the end of your cron job — curl https://pulsecheck-shivam.fly.dev/ping/<id> — and Cronbell expects that ping on a schedule you define. If the ping doesn’t arrive within the expected window, Cronbell assumes the job didn’t run and sends an alert. No ping ever needs to report success explicitly; showing up on time is the success signal.
Architecture
The whole point of a monitoring tool is that it has to be more reliable than the thing it’s watching, so I deliberately kept the first design boring and split into separately deployable pieces rather than one monolith:
- Web service (FastAPI) — auth, the dashboard, and the ping-ingest endpoint.
- Worker (Celery) — sends the actual alert emails asynchronously, off the request path.
- Scheduler (Celery Beat) — periodically scans for monitors whose expected ping window has elapsed.
- PostgreSQL — monitors, users, and ping history via SQLAlchemy models.
- Redis — the broker between the web service, worker, and scheduler.
Five containers, each independently scalable. That was the v1 design, meant to move cleanly from Docker Compose on a single box up to Kubernetes without a rewrite. It didn’t stay five containers for long.
The pivot: dropping the always-on worker
The Celery worker and Celery Beat scheduler were two processes that had to stay running 24/7 just to fire a check every couple of minutes. On Fly.io, that always-on compute turned out to be the single biggest line item on the bill, for something that mostly sits idle between checks.
v3 replaced both with a plain function behind an internal, token-guarded endpoint (POST /internal/run-overdue-check) that something external calls on a schedule. Redis stuck around, but only for request rate-limiting, not as a message broker. The web machine now auto-stops when idle and Postgres moved to Neon (Singapore, next to the Fly machine), so the whole stack can sleep between checks.
What a dead man’s switch needs from that trigger is boring reliability, and that’s where my first replacement was wrong. I started with a GitHub Actions cron in the repo. GitHub’s scheduler turned out to be too unreliable for the job — on a quiet repo it ran every few hours, which is a long time for a monitoring tool to sit on a missed ping. So a free external scheduler (cron-job.org) is now the primary trigger, and the GitHub workflow stays as a backup.
The check runs every 15 minutes rather than every 5, deliberately: it lets the web machine and the database both sleep between runs, which is what keeps the monthly bill aimed under Fly’s minimum-charge threshold. The trade-off is that an overdue job is noticed within roughly 15–30 minutes instead of within seconds. That’s fine for a cron-job monitor, and the README says so plainly instead of hiding it.
What’s shipped since
Past the core ping-and-alert loop, most of what’s gone in since is what turns a working script into something someone else could actually depend on. Slack, Discord, and generic webhook alerts alongside email, so a missed job doesn’t depend on someone checking their inbox. Opt-in public status pages, one per account, so a monitor’s uptime can be shared without sharing the dashboard. A per-account Prometheus /metrics endpoint, scoped by token, for pinning a monitor onto a Grafana board that already exists instead of asking anyone to visit yet another URL. Rate limiting on every state-changing endpoint and CSRF protection on every authenticated form — hardening that’s invisible right up until it isn’t. And a 52-test pytest suite that runs against an in-memory SQLite database in CI, so all of it is covered without needing Docker or a real Postgres instance just to run pytest.
A few decisions worth explaining
Server-rendered Jinja2 over a JS framework. For v1, a SPA would have bought me nothing — the dashboard is mostly forms and tables — and would have doubled the deploy surface. Server-rendered HTML keeps the whole thing deployable on the cheapest possible infrastructure, which mattered a lot for a side project.
JWT in HttpOnly cookies, not localStorage. Storing the auth token in an HttpOnly cookie means client-side JS never has access to it, which closes off a whole category of XSS-based token theft — a small decision, but one that’s easy to get wrong on a solo project with no code review.
Pointing it at this portfolio. This portfolio is fully static, so it can’t ping anything on its own. There’s a small GitHub Actions workflow in this repo that checks the live site and pings Cronbell only if it returns 200, which would make Cronbell the thing watching the site you’re reading this on. It’s switched off for now, and after the scheduler lesson above I’d drive it from the external trigger rather than GitHub’s own cron.
Where it stands right now
Cronbell is live as a free demo. It runs on a small Fly.io machine and a free Neon Postgres, so the first request after a quiet spell can take a few seconds. The hostname still says pulsecheck because it predates the rename; a custom domain is next, along with an authenticated sending domain so verification and alert emails stop depending on a personal Gmail sender. After that comes v5: “start” and “fail” ping variants, pricing, and team accounts. The source, README, and full commit history are on GitHub — or reach out and I’ll walk you through it live.