Cron jobs have no built-in failure notification. If a job exits with an error, nothing happens. No email, no Slack message, no red badge in a dashboard. The scheduler just schedules the next run. Your data pipeline breaks quietly, your cleanup job stops cleaning, your backup never backs up — and you find out days later from a user.
This guide covers every realistic option for getting a cron job failure alert email: from the quick one-liner hacks that developers reach for first, to the OS-level tools that actually work, to the monitoring approach that catches failures and missed runs.
Cron does have a built-in email mechanism. If a job produces output on stdout or stderr, cron will try to mail it to the local system user. In theory, set MAILTO=you@example.com at the top of your crontab and you're done.
In practice, this fails on most modern servers for several reasons:
sendmail either doesn't exist or exits silently without delivering anything./var/mail/root) is read by almost no one. It's a graveyard.MAILTO does nothing.The oldest trick: wrap your command in a shell one-liner that sends an email on failure. The pattern uses exit-code checking:
# Send email on failure using mail/sendmail
0 3 * * * /opt/jobs/sync.py || echo "sync.py failed on $(hostname) at $(date)" | mail -s "Cron job failed: sync.py" you@example.com
This works if — and only if — mail is installed and configured to actually deliver messages. On a stock Ubuntu 22 server with mailutils and a configured relay, this is a reasonable 30-second solution. On a container, a Lambda, or a cloud VM without a relay, it delivers nothing.
A slightly more portable version uses curl to hit an email API like SendGrid or Mailgun directly:
0 3 * * * /opt/jobs/sync.py || curl -s -X POST https://api.sendgrid.com/v3/mail/send \
-H "Authorization: Bearer $SENDGRID_KEY" \
-H "Content-Type: application/json" \
-d '{"personalizations":[{"to":[{"email":"you@example.com"}]}],"from":{"email":"alerts@yourapp.com"},"subject":"Cron job failed","content":[{"type":"text/plain","value":"sync.py failed"}]}'
This works anywhere curl is available, but now you're managing an API key in crontab, maintaining an inline JSON payload, and handling the secret securely. It gets messy fast.
If your server runs systemd, you can convert your cron job into a systemd timer and use the OnFailure directive to trigger a notification unit when the job fails:
# /etc/systemd/system/sync.service
[Unit]
Description=Nightly sync job
OnFailure=notify-failure@%n.service
[Service]
Type=oneshot
ExecStart=/opt/jobs/sync.py
# /etc/systemd/system/notify-failure@.service
[Unit]
Description=Send failure email for %i
[Service]
Type=oneshot
ExecStart=/usr/bin/bash -c 'echo "Service %i failed" | mail -s "Cron job failed: %i" you@example.com'
This is the most robust OS-level solution for traditional Linux servers. It handles exit codes, crash signals, and timeouts. The downside: it requires root access, doesn't work in containers or serverless environments, and still depends on a working mail stack for the final delivery step.
The most reliable approach, and the only one that works consistently across crontab, Lambda, GitHub Actions, Kubernetes, and Docker, is to use an external monitoring service. Your job pings a URL on success; if the ping doesn't arrive, the service sends you an alert.
This is covered in detail in the next section.
Alerting on failures is necessary but not sufficient. Consider these scenarios that failure-only alerting misses:
A dead man's switch (also called a heartbeat monitor) flips the detection model: instead of alerting when something goes wrong, you alert when an expected signal stops arriving. Your job sends one HTTP ping at the end of each successful run. If the monitoring service doesn't receive that ping within the expected window, it fires an alert.
# crontab — ping a monitoring service on success
0 3 * * * /opt/jobs/sync.py && curl -sf https://cron.watch/ping/your-job-id
# GitHub Actions — only ping if the job step succeeds
- name: Run sync
run: python sync.py
- name: Notify CronWatch
if: success()
run: curl -sf https://cron.watch/ping/your-job-id
The && operator ensures the ping only fires when the job exits with code 0. A failed job, a killed job, or a job that never runs — all three result in a missing ping, and all three trigger the same alert within minutes of the expected window.
CronWatch is built around the dead man's switch pattern. You add one line to your cron job — a curl ping to your CronWatch endpoint. CronWatch tracks the expected schedule and sends you an email alert within minutes if a run is late, silent, or failed.
No agent to install. No SMTP server to configure. No wrapper scripts to maintain. Works across every environment that has curl: bare metal crontab, GitHub Actions, Lambda, Kubernetes CronJob, Heroku Scheduler, Cloud Run, Render.
# Before (no monitoring)
0 3 * * * /opt/jobs/nightly-sync.py
# After (CronWatch monitoring — one extra token)
0 3 * * * /opt/jobs/nightly-sync.py && curl -sf https://cron.watch/ping/abc123
You define the schedule and the grace period. If CronWatch doesn't see a ping within that window — whether because the job failed, was killed, or never started — you get an email. You can also configure alerts for when a job starts running but never completes, catching jobs that hang indefinitely.
The result: you stop checking whether your cron jobs are running. You know they're running because CronWatch would have emailed you if they weren't.
CronWatch monitors your scheduled jobs across every platform — crontab, GitHub Actions, Lambda, Kubernetes, Heroku, Render. One curl line. Email alerts within minutes of a missed or failed run.
Join the CronWatch waitlist → Parse a cron expression free