Friday afternoon: your invoice email job runs at 11 PM and has run every Friday for two years without incident. Thursday night it silently stops — a botched deploy removed an environment variable, and the scheduler skipped it without logging anything. Monday morning a customer emails asking where their invoice is. That's when you find out.
Error monitoring wouldn't have caught this. There was no error. There was no crash. No stack trace, no exit code, no log entry. The job simply stopped running, and the absence of output looked exactly like normal silence.
This is the problem a dead man's switch solves.
The concept comes from safety engineering: a control that requires a human operator to actively confirm they are present and functioning. If the operator stops confirming — falls unconscious, steps away — the switch triggers a safety action. No signal = something is wrong.
Applied to cron jobs, the model flips how monitoring works. Instead of an external observer trying to watch the job from the outside (which requires knowing the schedule and just checking whether a process started), the job itself pings an endpoint at the end of each successful run. If the ping stops arriving within the expected window, you get alerted.
This is more reliable than external scheduling monitors for one critical reason: it knows the job actually ran to completion, not just that it started. A job that starts and crashes 30 seconds in looks identical to a healthy job from the outside. From the inside — from the heartbeat — it's obvious: the ping never came.
The three failure modes a dead man's switch catches that standard monitoring misses:
The smallest possible implementation: add a single line at the end of your cron script. The heartbeat only fires if the script reaches that line — meaning everything above it succeeded.
# At the end of your cron script
curl -fsS "https://your-monitoring-service.com/heartbeat/TOKEN" > /dev/null 2>&1
In /etc/cron.d/myjob, chain it with && so the heartbeat only fires on a clean exit:
0 3 * * * deploy /usr/bin/python /path/to/sync.py && curl -fsS "https://hb.example.com/ping/TOKEN" > /dev/null
The -fsS flags make curl fail silently on HTTP errors without producing output that would trigger a local mail notification. The redirect to /dev/null suppresses any remaining output. This pattern adds roughly 50ms to your job's runtime and zero additional dependencies.
The key insight: the absence of the ping IS the alert. You don't need to handle the error case — just don't ping, and the monitoring service handles the rest.
For longer jobs or ones you want to wrap with retry logic, a Python helper function keeps the heartbeat pattern clean:
import subprocess
import requests
def run_with_heartbeat(cmd, heartbeat_url, timeout=300):
try:
result = subprocess.run(cmd, timeout=timeout, check=True)
requests.get(heartbeat_url, timeout=5) # ping on success only
except Exception as e:
# Don't ping — the absence of the ping IS the alert
raise
Usage is straightforward:
run_with_heartbeat(
["python", "/path/to/sync.py"],
heartbeat_url=os.environ["CRONWATCH_HEARTBEAT_URL"]
)
The check=True argument makes subprocess.run raise a CalledProcessError on non-zero exit codes, which the except block catches and re-raises without pinging. timeout=5 on the heartbeat request itself prevents a hung monitoring service from blocking your job indefinitely.
GitHub Actions workflows support a schedule: trigger using cron syntax. The if: success() condition on the heartbeat step ensures the ping only fires when all previous steps passed:
- name: Run job
run: python sync.py
- name: Ping heartbeat
if: success()
run: curl -fsS "${{ secrets.HEARTBEAT_URL }}"
GitHub Actions has its own silent failure mode worth knowing: scheduled workflows stop running after 60 days of repo inactivity with no notification. A dead man's switch catches this case automatically — if GitHub stops triggering the workflow, the heartbeat stops arriving.
You could build the server side yourself: a small web service that accepts GET requests and triggers an email after a configurable timeout. It's a weekend project. But it comes with operational cost you might not immediately account for:
| DIY | CronWatch | |
|---|---|---|
| Setup time | 30–60 min (initial), ongoing maintenance | 2 minutes (one curl line) |
| Grace period config | Manual code change | UI slider per monitor |
| Alert channels | Email (custom code required) | Email, Slack |
| Dashboard | None | Visual timeline per job |
| Multi-job management | More config per job | One dashboard, all jobs |
| Uptime of monitor itself | Your problem | Managed |
For a single job at a small company, the DIY path is reasonable. For five or more jobs at a company where missed runs have business consequences, the operational overhead of running your own monitoring infrastructure starts to outweigh the cost of a service.
Regardless of approach, a complete dead man's switch setup should include:
Add one curl line to your cron job. We handle the grace periods, the alerts, and the dashboard. Works with any language, any scheduler: crontab, GitHub Actions, Kubernetes CronJob, Heroku Scheduler, Lambda, Celery Beat, and more.
Free to try. No credit card required.
Join the CronWatch waitlist Free cron expression parser →