← Back to Blog

Case Study

Building PulseCheck: a dead-man’s-switch monitor for cron jobs

September 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 PulseCheck to catch that class of failure specifically. It’s a live production service — not a toy project — currently at github.com/shane-Coder/PulseCheck.

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 PulseCheck 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 PulseCheck expects that ping on a schedule you define. If the ping doesn’t arrive within the expected window, PulseCheck assumes the job didn’t run and fires an email 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 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 — the design is meant to move cleanly from Docker Compose on a single box up to Kubernetes without a rewrite, even though right now it runs happily as containers on Fly.io.

The pivot: dropping the always-on worker

That architecture was v1, and it worked — but 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, bigger than the web service and the database combined, for something that mostly sits idle between checks.

v3 replaced both with a GitHub Actions cron: the overdue-monitor sweep is now a plain function behind an internal, token-guarded endpoint (POST /internal/run-overdue-check), and a workflow in the PulseCheck repo itself calls it on a schedule instead. Redis stuck around, but only for request rate-limiting now, not as a message broker. Always-on compute is down to Postgres and the web process — a change small enough to describe in one paragraph, and the kind of decision that only shows up on the infra bill, never in a demo.

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 41-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.

The portfolio site monitors itself, sort of. This portfolio is fully static — it can’t ping anything on its own. So there’s a small GitHub Actions workflow in this repo that checks the live site on a schedule and only pings PulseCheck if the check actually returns 200. In effect, PulseCheck ends up watching the uptime of the very site you’re reading this on — using itself as its own first real user.

Where it stands right now

Being honest about the current state: the work above has shipped — the worker's gone, the webhook alerts and status pages work, the test suite passes in CI — but the public demo is still sitting behind a maintenance page while I finish relaunching it cost-optimized, so the link below currently lands there rather than the dashboard. The source, README, and full commit history are always up on GitHub if you want to look at the actual implementation in the meantime — or reach out and I’ll walk you through it live.

FastAPIPostgreSQLSQLAlchemyRedisGitHub ActionsDockerFly.io