Cron is one of the most reliable pieces of software ever written. It almost never crashes. But the jobs it runs? They fail all the time — and cron doesn't care. It just moves on to the next scheduled run and says nothing.
The result is a class of production failures that nobody notices until a user complains, a report is missing, or a backup that "should have been running for months" turns out to be empty. Cron job monitoring is how you close that gap.
By default, cron captures stdout and stderr and tries to mail them to the local system user. In practice, on most modern servers, that mail never goes anywhere. There's no outbox, no inbox, no notification. The job runs, produces output, and the output disappears.
Even when jobs don't produce output — because they succeed — you still have a monitoring gap: you have no external evidence the job ran at all. A missed run looks identical to a successful run from the outside.
The three failure modes you actually need to catch:
Effective cron job monitoring covers four dimensions:
Did the job run in the expected window? A job scheduled at 2am that hasn't been seen since yesterday is a silent failure. This is the most important signal and the one most monitoring tools are built around.
Did the job succeed? An exit code of 0 means success; anything else means the job reported failure. This is table stakes but surprisingly often ignored — many people log output without capturing exit codes.
How long did it take? Duration tracking catches jobs that are hanging (run time increasing over weeks until they hit a timeout), jobs that fail too fast (finished in 1 second when they normally take 5 minutes), and jobs that are degrading (slowly getting slower over time as a table grows).
Did the job miss its schedule entirely — not just fail, but never start? This requires an external observer. If your monitoring system lives on the same server as your cron daemon, a server outage kills both the job and the monitor simultaneously. A missed window check requires an off-host observer.
This is the most effective approach for most teams. The pattern is simple: your cron job sends an HTTP ping to an external service when it finishes successfully. The external service watches for that ping on a defined schedule. If the ping doesn't arrive in the expected window, it alerts you.
This is called a dead man's switch — the absence of a signal is itself the alert.
Popular services in this space: healthchecks.io, Dead Man's Snitch, and CronWatch. Each works on the same principle: you get a unique URL, you add a curl call to your job, and the service pings you if your job goes quiet.
Why this approach works well:
Pipe cron output to a log file and use a log aggregation tool (Datadog, CloudWatch Logs, Papertrail, Logtail) to alert on patterns:
# In your crontab: redirect all output to a log file
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
You can then set up log-based alerts for error strings, missing log entries over a time window, or unexpected exit codes.
The downside: log-based monitoring only catches failure messages that appear in logs. A missed run produces no log entry at all — you'd need to set up a separate "expected log pattern" alert, which is more complex than it sounds and still doesn't work if the logging agent itself goes down.
Cloud schedulers often have native observability:
FailedInvocations or on the absence of Invocations over a period.kubectl get cronjobs shows LAST SCHEDULE and ACTIVE; Prometheus + kube-state-metrics exposes kube_cronjob_status_last_schedule_time for alerting.Platform-native monitoring is good when you're already deeply invested in that platform's observability stack. It gets complicated fast if your jobs span multiple platforms.
Roll your own: write a wrapper script that captures exit code, start time, and end time, writes them to a table, and sends you an email or Slack message on failure:
#!/bin/bash
# cron-wrapper.sh — captures exit code and duration, sends Slack alert on failure
JOB_NAME="$1"
shift
START=$(date +%s)
"$@"
EXIT_CODE=$?
DURATION=$(( $(date +%s) - START ))
if [ $EXIT_CODE -ne 0 ]; then
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\": \"🚨 Cron job *${JOB_NAME}* failed with exit code ${EXIT_CODE} after ${DURATION}s\"}"
fi
exit $EXIT_CODE
Use in crontab:
0 2 * * * /usr/local/bin/cron-wrapper.sh "nightly-backup" /usr/local/bin/backup.sh
This catches failures but not missed runs — the wrapper only fires if the job actually starts. You'd need a second job that checks whether the first job ran recently, which starts to become its own reliability problem.
| Feature | Why it matters |
|---|---|
| Alerting latency | How quickly does it notify you after a missed run? Some tools check every minute; others poll every 5–15 minutes. A backup job that fails at 2am should alert before 9am when someone notices. |
| Multi-platform support | Can it monitor crontab, Heroku Scheduler, GitHub Actions scheduled workflows, and Kubernetes CronJobs from one dashboard? Most teams end up with jobs on multiple platforms. |
| Grace periods | Jobs don't always run exactly on time — a loaded server may delay a job by a few minutes. Good tools let you set a grace period so you're not paged for a job that ran 90 seconds late. |
| Alert channels | Email, Slack, PagerDuty, SMS. You want alerts to go where your team actually looks, especially at 3am. |
| Price | Most tools charge per monitor. Indie developers and small teams can easily have 20–50 cron jobs. Per-monitor pricing adds up; look for flat-rate plans or generous free tiers. |
| Ease of setup | The best tools require adding a single curl line to an existing job. If setup requires installing an agent, modifying your deployment pipeline, or configuring a sidecar, the activation cost is high enough that it won't happen for most jobs. |
The dead man's switch pattern reduces to a single curl call appended to your existing job. Here's the canonical form:
# Pattern: run job, then ping monitor URL on success
0 2 * * * /usr/local/bin/backup.sh && curl -fsS --retry 3 https://monitor-url/your-token
The && means the curl only fires if backup.sh exits 0. If the job fails, no ping is sent, and the monitoring service alerts you after the grace period expires.
Flags explained:
-f — fail silently on HTTP errors (don't pollute cron output)-s — silent mode (suppress progress meter)-S — show errors despite -s (if curl itself fails, you want to see why)--retry 3 — retry up to 3 times on transient network errorsimport requests
MONITOR_URL = "https://cronwatch.io/ping/YOUR-TOKEN"
def run_job():
# Your actual job logic here
sync_database()
generate_report()
def main():
try:
run_job()
# Only ping on successful completion
requests.get(MONITOR_URL, timeout=5)
except Exception as e:
print(f"Job failed: {e}")
# Don't ping — the monitor will alert when the ping is late
raise SystemExit(1)
if __name__ == "__main__":
main()
Wrap in a crontab entry:
0 2 * * * /usr/bin/python3 /opt/jobs/sync.py
The monitor URL is called only when run_job() completes without raising. Any unhandled exception causes a non-zero exit, no ping is sent, and the monitoring service alerts you.
const MONITOR_URL = process.env.MONITOR_URL || 'https://cronwatch.io/ping/YOUR-TOKEN';
async function runJob() {
// Your actual job logic here
await syncDatabase();
await generateReport();
}
async function main() {
try {
await runJob();
// Only ping on successful completion
await fetch(MONITOR_URL, { signal: AbortSignal.timeout(5000) })
.catch(() => {}); // Don't let monitor failure break the job
process.exit(0);
} catch (err) {
console.error('Job failed:', err);
// Don't ping — the monitor alerts when ping is late
process.exit(1);
}
}
main();
For older Node.js without fetch built in, use https.get() or axios:
// axios alternative
const axios = require('axios');
await axios.get(MONITOR_URL, { timeout: 5000 }).catch(() => {});
For jobs where you want to capture both start and finish (to track duration and detect jobs that hang):
# Ping on start, then ping on finish — lets the monitor track duration
0 2 * * * curl -fsS --retry 3 https://monitor-url/your-token/start \
&& /usr/local/bin/backup.sh \
&& curl -fsS --retry 3 https://monitor-url/your-token
This works identically for any scheduler that runs a shell command:
# Heroku Scheduler (Procfile-style)
backup: ./bin/backup.sh && curl -fsS --retry 3 https://monitor-url/your-token
# GitHub Actions scheduled workflow
- name: Run backup
run: ./bin/backup.sh && curl -fsS --retry 3 https://monitor-url/${{ secrets.MONITOR_TOKEN }}
If you want a purpose-built dead man's switch that works across crontab, GitHub Actions, Heroku Scheduler, and Kubernetes CronJobs — without building and maintaining your own alerting infrastructure — CronWatch is designed exactly for this.
Setup is the single curl-line pattern above. CronWatch handles the schedule tracking, missed-run detection, grace periods, and alerts. You get a dashboard that shows every job's last run time, exit status, and duration history.
CronWatch monitors your scheduled jobs with dead man's switch detection — alerting you the moment a job misses its window, not after you notice results are missing. Works with crontab, GitHub Actions, Heroku Scheduler, and Kubernetes CronJobs.
Join the CronWatch waitlist → Validate your cron expressionNot sure your cron schedule is correct? Use the free cron expression parser to see the plain-English description and the next five run times before you deploy.
Monitoring is the act of collecting data about job runs — whether they ran, whether they succeeded, and how long they took. Alerting is what happens when the monitoring data shows something wrong: you receive a notification. Both are necessary. Monitoring without alerting is just data nobody looks at. Alerting without monitoring means you're guessing about what actually went wrong.
Use the dead man's switch pattern with a single shared monitor URL. If any one of your servers successfully completes the job, it sends the ping. If all of them fail or none of them run in the expected window, the ping is absent and you get an alert. You don't need per-server monitors — you need a monitor per job, regardless of how many servers run it.
Yes. Add a final step that curls the monitor URL only on success:
- name: Ping monitor on success
if: success()
run: curl -fsS --retry 3 https://cronwatch.io/ping/YOUR-TOKEN
Important: GitHub Actions disables scheduled workflows on repositories with no recent activity (~60 days). A dead man's switch will catch this automatically — the workflow stops running, no ping arrives, you get alerted.
Set the grace period to about 1.5× the longest the job has ever taken to run. If your nightly backup normally runs in 8 minutes but occasionally takes up to 20, set the grace period to 30 minutes. This prevents false alarms from slow runs while still catching genuinely missed jobs quickly. Start conservative and tighten it once you have a few weeks of run-time data.
This is a real concern. The better monitoring services publish SLA uptime numbers and run redundant infrastructure. For critical jobs, consider sending pings to two different services (a few extra milliseconds, significant reliability benefit). For most jobs, a single external service with good uptime is sufficient — the alternative (no monitoring) is strictly worse.
No. Focus on jobs whose failure has a user-visible impact or a material cost. Good candidates: data syncs, report generation, billing runs, backup jobs, any job that feeds a customer-facing feature. Skip monitoring for jobs whose failure is immediately obvious (they produce real-time output) or truly inconsequential (e.g., a stats counter that's nice-to-have).