A cron job health check is a URL your job pings at the end of every successful run. If the ping stops arriving — because the job crashed, was skipped, or the server went down — a monitoring service notices and sends you an alert.
This pattern is called a dead man's switch. It's the most reliable way to verify a cron job ran, because it doesn't require reading logs, watching dashboards, or guessing from output files. Silence is the alert.
Add a curl call at the end of any cron job:
# Your existing cron entry — replace the command with yours
*/30 * * * * /path/to/your/script.sh && curl -fsS --retry 3 https://your-monitor.example.com/ping/YOUR-TOKEN > /dev/null 2>&1
The && means the ping only fires if the script exited with code 0. A non-zero exit (failure) means no ping — which triggers the alert. The monitoring service expects a ping within your grace window; when it doesn't arrive, you get notified.
If you prefer keeping the ping unconditional (ping regardless of exit code, so you know the job ran but can track failures separately):
*/30 * * * * /path/to/your/script.sh; curl -fsS --retry 3 https://your-monitor.example.com/ping/YOUR-TOKEN > /dev/null 2>&1
Use ; instead of && to separate the two commands.
Edit your crontab with crontab -e. Append the curl after your command:
# Append on success:
0 2 * * * /usr/local/bin/backup.sh && curl -fsS --retry 3 https://your-monitor/ping/TOKEN > /dev/null
# Append unconditionally:
0 2 * * * /usr/local/bin/backup.sh; curl -fsS --retry 3 https://your-monitor/ping/TOKEN > /dev/null
Add a step at the end of your job:
name: Nightly sync
on:
schedule:
- cron: '0 2 * * *'
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run sync
run: ./scripts/sync.sh
- name: Health check ping
if: success()
run: curl -fsS --retry 3 ${{ secrets.CRONWATCH_PING_URL }}
Store the ping URL as a GitHub Actions secret. The if: success() condition means the ping only fires when all prior steps passed.
Add the health check ping as the last call in your Lambda handler:
import urllib.request
def handler(event, context):
# Your existing logic here
do_the_work()
# Health check ping at the end
urllib.request.urlopen(
'https://your-monitor/ping/YOUR-TOKEN',
timeout=5
)
return {'statusCode': 200}
Wrap the ping in a try/except so a network blip doesn't fail the Lambda itself:
try:
urllib.request.urlopen('https://your-monitor/ping/YOUR-TOKEN', timeout=5)
except Exception:
pass # don't let ping failure break the job
import cron from 'node-cron';
import fetch from 'node-fetch';
cron.schedule('0 2 * * *', async () => {
try {
await doTheWork();
// Ping on success
await fetch(process.env.CRONWATCH_PING_URL, { signal: AbortSignal.timeout(5000) });
} catch (err) {
console.error('Job failed:', err);
// No ping = alert fires
}
});
import requests
def nightly_job():
do_the_work()
try:
requests.get(
os.environ['CRONWATCH_PING_URL'],
timeout=5
)
except requests.RequestException:
pass # don't let a network blip break the job
scheduler.add_job(nightly_job, 'cron', hour=2)
The grace period is how long the monitoring service waits after the expected ping time before alerting. Set it to roughly 2× your typical job duration:
| Job schedule | Typical duration | Suggested grace |
|---|---|---|
| Every 5 minutes | 10–30 sec | 2–5 minutes |
| Hourly | 1–5 min | 10–15 minutes |
| Daily at 2am | 5–30 min | 60 minutes |
| Weekly (heavy ETL) | 1–4 hours | 2 hours |
A grace period that's too tight generates false alerts on slow runs. Too loose and you're waiting hours before learning a job missed. When in doubt, start conservative (longer grace) and tighten after you see typical run durations.
A common alternative to health check pings is reading logs: if the job ran, there should be a log entry. The problem:
Health check pings flip the model: instead of looking for evidence a job ran, you get alerted when it didn't. That's a fundamentally more reliable detection pattern.
After adding the ping, do a smoke test:
./your-script.shStep 5 is the one most people skip. If you've never seen an alert fire, you don't know the alert works. Test it intentionally before you're relying on it at 2am.
The ping endpoint should:
GET or HEAD request (both work; HEAD is marginally lighter)The simplest possible implementation you can self-host:
# Express.js — minimal ping receiver
app.get('/ping/:token', async (req, res) => {
await db.query(
'UPDATE monitors SET last_ping_at = NOW() WHERE token = $1',
[req.params.token]
);
res.json({ ok: true });
});
You'll also need the background checker that reads last_ping_at and fires an alert when it's overdue. That's roughly another 20–30 lines of code plus email or Slack delivery. Or you can use an existing service that handles the endpoint, storage, and alert routing for you.
CronWatch gives you a ping URL per job. Add one curl line. Get alerted by email the moment a job is late. No infrastructure to run, no dashboards to check.
Free cron expression parser (check your schedule before you add the ping):
Parse my cron expression → Join the CronWatch waitlist →&& curl -fsS https://your-monitor/ping/TOKEN > /dev/null to any cron entry