CronDrive
Guides

How to get an email when a cron job fails

Updated 1 November 2026

Cron can already send email: everything a job prints goes to the address in MAILTO. In practice, three things go wrong: the server has no way to send email, the emails arrive for every run, and a job that fails without printing anything sends nothing. Here’s how to fix each one, and how to alert a chat instead.

How MAILTO works

MAILTO=you@example.com
0 3 * * * /path/to/backup.sh
  • Cron emails the output of each run, both standard output and errors. If the job prints nothing, there’s no email.
  • MAILTO applies to the lines below it, until another MAILTO changes it. MAILTO="" turns the emails off.
  • Without MAILTO, the email goes to the crontab’s user on the same machine, in /var/mail/<user>, where nobody reads it.

Send cron’s email through SMTP with msmtp

Cron hands its emails to /usr/sbin/sendmail, and most servers don’t have it. msmtp provides it and sends the emails through an SMTP account, from your email provider or a service like Amazon SES. On Debian and Ubuntu:

sudo apt install msmtp-mta

Write its configuration in the home directory of the crontab’s user, in ~/.msmtprc (for root’s crontab, /root/.msmtprc). Replace host, port, from, user and password with your provider’s details:

defaults
auth on
tls on
syslog on

account default
host smtp.example.com
port 587
from cron@example.com
set_from_header on
user cron@example.com
password the-password
chmod 600 ~/.msmtprc
  • from is the sender, the account that sends the emails; MAILTO, in the crontab, is who receives them. They can be the same address.
  • The file has the password, so msmtp refuses to use it if other users can read it.
  • Port 587 with tls on uses STARTTLS. If your provider uses port 465, add tls_starttls off.
  • set_from_header on replaces the sender that cron writes, like root (Cron Daemon), with the address in from. Many providers only accept addresses of your account.
  • With Gmail, the host is smtp.gmail.com on port 587, and the password is an app password, which needs 2-Step Verification on the account.
  • syslog on logs each email sent or rejected, which you can read with journalctl -t msmtp.

Test msmtp first, as the crontab’s user:

printf 'Subject: msmtp test\n\nIt works.\n' | msmtp you@example.com

Then test cron with a line that prints something every minute, and remove it when the email arrives:

MAILTO=you@example.com
* * * * * echo "cron email test"

Other distributions have msmtp too: cron only needs /usr/sbin/sendmail to be msmtp, or another program that sends email.

Only when the job fails

A job that prints something on every run sends an email on every run, and soon nobody reads them. There are two ways to get them only when the job fails.

With chronic, from moreutils, which hides the output when the command succeeds and shows it when it fails:

sudo apt install moreutils
MAILTO=you@example.com
0 3 * * * chronic -v /path/to/backup.sh

chronic ends with the same exit code as the job. Use -v: without it, a job that fails without printing anything sends no email. With it, the output comes labeled, and ends with RETVAL: and the exit code, so the job always produces output when it fails, and cron has something to email.

Without chronic, keep the output in a file and print it, with the exit code, only when the job fails:

MAILTO=you@example.com
0 3 * * * /path/to/backup.sh > /tmp/backup.log 2>&1 || { code=$?; echo "backup.sh failed with exit code $code"; cat /tmp/backup.log; }

Use a different file for each job, so that two jobs don’t write to the same one.

Why the email doesn’t arrive

  1. The job printed nothing. A run that succeeds quietly, or fails without printing anything, has nothing to send. Test with the echo line above.
  2. There’s no /usr/sbin/sendmail. On Debian and Ubuntu, cron logs No MTA installed, discarding output. Look for it with grep CRON /var/log/syslog or journalctl -u cron (more in cron logs).
  3. msmtp couldn’t send it. journalctl -t msmtp shows the SMTP server’s answer: a wrong password, a rejected sender, a blocked port.
  4. MAILTO doesn’t apply to the line. It’s below the line, it’s empty, or another MAILTO between them changes it.
  5. It went to spam. Check the spam folder, and that the sender’s domain has SPF and DKIM for the server that sends it.
  6. The job runs in a container. Containers rarely have a way to send email. Send the output to docker logs (see how to run cron jobs in a Docker container) or alert a chat, as below.

Alert a chat instead

Without a mail server, the crontab line can call a webhook when the job fails. In these lines, || runs the alert only if the job fails, and code=$? saves the job’s exit code first, so that the message can include it. For a Slack incoming webhook:

0 3 * * * /path/to/backup.sh || { code=$?; curl -fsS -m 10 -H 'Content-Type: application/json' -d "{\"text\":\"backup.sh failed on $(hostname) with exit code $code\"}" https://hooks.slack.com/services/...; }

For a Discord webhook, the same line with content instead of text:

0 3 * * * /path/to/backup.sh || { code=$?; curl -fsS -m 10 -H 'Content-Type: application/json' -d "{\"content\":\"backup.sh failed on $(hostname) with exit code $code\"}" https://discord.com/api/webhooks/...; }

For a notification on your phone with ntfy, without an account:

0 3 * * * /path/to/backup.sh || { code=$?; curl -fsS -m 10 -H "Title: backup.sh failed" -d "On $(hostname), exit code $code" https://ntfy.sh/your-topic; }

To receive it, install the ntfy app on your phone and subscribe to the same topic you put in the URL (how to do it). Anyone who knows an ntfy.sh topic can read it, so choose a long, random name.

Save the code with code=$? before anything else, as in these lines: if the crontab uses bash (SHELL=/bin/bash), a $(hostname) written before $? in the same command resets it, and the alert says the job failed with code 0.

What these alerts can’t tell you

The email and the webhook are sent by the same line, on the same machine, after the job. If the job doesn’t run, because the server is down, cron is stopped or someone replaced the crontab, or if it hangs and never ends, there’s no email and no message. To also find out about those, you need a service that expects a signal from the job within a set time and alerts you when it doesn’t arrive (more in how to monitor cron jobs).

With CronDrive

Replace the line with one that calls CronDrive at the start and at the end, with the exit code:

0 3 * * * curl -fsS -m 10 https://job.crondrive.com/<token>/start/$$; /path/to/backup.sh; curl -fsS -m 10 https://job.crondrive.com/<token>/end/$?/$$

CronDrive sends the email, with no mail server on your machine, and can also post to Slack, Discord, Microsoft Teams, Telegram or PagerDuty. It alerts you when the job fails, and also when it doesn’t start or doesn’t finish in the minutes you allow. When you create the job in CronDrive, it gives you this line with the job’s own token in place of <token>.

CronDrive emails you, or posts to Slack, when a job with this schedule fails, or doesn't start or finish on time. Free for up to 15 jobs.

What you'd see with this schedule

An example with the last runs of this schedule and made-up results: one failed and one didn't run.

Times in UTC.

Due
Sun 1 Nov, 03:00 On time Started 03:00, finished 03:13, took 13 min.
Sat 31 Oct, 03:00 Didn't run Alerts sent at 03:05 and 03:30.
Fri 30 Oct, 03:00 On time Started 03:00, finished 03:14, took 14 min.
Thu 29 Oct, 03:00 Failed with code 1 Alert sent at 03:04. Started 03:00, finished 03:04, took 4 min.
Wed 28 Oct, 03:00 On time Started 03:00, finished 03:13, took 13 min.
Tue 27 Oct, 03:00 On time Started 03:00, finished 03:14, took 14 min.

The alert of the failed run

Email from CronDrive, Thu 29 Oct, 03:04

Failed (exit code 1): "Your job"

The job "Your job" failed with exit code 1.

If you connect Slack, the same message goes to your channel.

The change in your crontab

0 3 * * * curl -fsS -m 10 https://job.crondrive.com/uG2Exw/start/$$; /path/to/job.sh; curl -fsS -m 10 https://job.crondrive.com/uG2Exw/end/$?/$$

Two calls around your command, in the same line. Your job's line comes with its own token.

Monitor this schedule