Logging from cron jobs to a single tail-able file (and why your cron emails are silently failing)

You set up a cron job. It runs nightly at 3 AM. Some nights it succeeds; some nights it doesn’t — but you never know which because cron’s only feedback is supposed to be email, and email-from-cron has been silently broken on most modern servers for about 15 years. The fix isn’t to debug postfix or msmtp; it’s to skip the email layer entirely, log every cron job to a single tail-able file, and have a separate alerting layer (Healthchecks.io, Pushover, a Slack webhook) that pings you when something goes wrong.

Here’s what that setup actually looks like.

Why your cron emails aren’t reaching you

  • No MTA installed. Modern Ubuntu / Debian / Alma minimal images don’t install postfix or sendmail by default. cron writes to /var/spool/mail/$USER, which doesn’t get delivered anywhere. The mail just sits there silently growing.
  • MTA installed but unconfigured. Postfix on a fresh box defaults to local-only delivery. Mail is delivered to /var/mail/root, which you never read. Worst-case it tries to send to $USER@$HOSTNAME, which doesn’t exist.
  • Outbound port 25 blocked. Cloud providers (DO, AWS, Hetzner) block outbound SMTP by default to prevent spam from compromised instances. So even if your MTA is configured, the actual send fails silently.
  • SPF/DKIM not set up for the from-address. Even if delivery succeeds, Gmail / iCloud / Outlook drop or quarantine cron mail because it lacks authentication records. You search “cron” in your inbox and find nothing.

The fix isn’t to make any of these work. It’s to give up on cron-as-email entirely.

Step 1 — mute cron’s email machinery

# at the top of /etc/crontab or `crontab -e`:
MAILTO=""

# This tells cron to send mail nowhere.
# Without it, every cron'd command's stdout/stderr is treated as
# a message to be mailed, which fails silently and clutters logs.

Step 2 — log every job to one file with timestamps

Wrap each cron line so its output goes to /var/log/cron-jobs.log with a timestamp prefix. Don’t do this with redirection in the crontab itself — it’s awkward and can’t add timestamps. Use a tiny wrapper:

# /usr/local/bin/cronlog
#!/usr/bin/env bash
# Usage: cronlog <tag> -- <command...>
# Logs the command's output to /var/log/cron-jobs.log with timestamps.
set -uo pipefail

TAG="$1"
shift
[[ "$1" == "--" ]] && shift

START=$(date -u -Iseconds)
{ echo "=== $START [$TAG] start ==="
  "$@" 2>&1
  rc=$?
  echo "=== $(date -u -Iseconds) [$TAG] end rc=$rc ==="
  exit $rc
} 2>&1 | sed -u 's|^|'"$TAG | "'|' >> /var/log/cron-jobs.log

Make it executable: chmod +x /usr/local/bin/cronlog. Then crontab entries become:

30 3 * * * /usr/local/bin/cronlog backup -- /usr/local/sbin/backup-restic
*/5 * * * * /usr/local/bin/cronlog redis-check -- /usr/local/sbin/check-redis

Now tail -f /var/log/cron-jobs.log shows every cron job’s output as it happens, prefixed with a tag and a timestamp. No mail. No silence.

Step 3 — rotate the log

# /etc/logrotate.d/cron-jobs
/var/log/cron-jobs.log {
    weekly
    rotate 8
    compress
    delaycompress
    notifempty
    missingok
    create 0640 root adm
}

Logrotate runs nightly via /etc/cron.daily/logrotate. After 8 weeks the log file is gzipped and rotated. Adjust retention to taste.

Step 4 — the actual alerting

Logging is great for debugging post-hoc; it’s not how you find out a job failed at 3 AM. For that you want active alerting. Two clean approaches, both better than email-from-cron:

  • Healthchecks.io heartbeats. Each cron job pings a unique URL on success and a different one on failure. If HC.io doesn’t see a heartbeat in the expected window, it alerts. Free tier covers 20 checks. The script needs two extra lines (curl on success, curl with /fail on failure).
  • Pushover / Slack webhook on failure only. Wrap the cron entry: if exit-code is non-zero, send the last 50 log lines to a webhook. No background service needed.
# /usr/local/bin/cronlog-alert
#!/usr/bin/env bash
# Like cronlog, but also pings a webhook on failure.
set -uo pipefail
TAG="$1"; shift; [[ "$1" == "--" ]] && shift
WEBHOOK="https://hooks.slack.com/services/T.../B.../..."

START=$(date -u -Iseconds)
TMP=$(mktemp)
{ echo "=== $START [$TAG] start ==="
  "$@" 2>&1
  rc=$?
  echo "=== $(date -u -Iseconds) [$TAG] end rc=$rc ==="
  if [ "$rc" -ne 0 ]; then
    BODY=$(tail -50 "$TMP")
    curl -sS -X POST "$WEBHOOK" \
         -H 'Content-Type: application/json' \
         -d "$(jq -n --arg t "$TAG rc=$rc" --arg b "$BODY" \
                '{text:("CRON FAIL: "+$t+"\n```"+$b+"```")}')" >/dev/null
  fi
  exit $rc
} 2>&1 | tee "$TMP" | sed -u 's|^|'"$TAG | "'|' >> /var/log/cron-jobs.log
rm -f "$TMP"

Now in crontab:

30 3 * * * /usr/local/bin/cronlog-alert backup -- /usr/local/sbin/backup-restic
*/5 * * * * /usr/local/bin/cronlog-alert redis-check -- /usr/local/sbin/check-redis

Successful runs go to the log file silently. Failures go to Slack with the last 50 lines as context. You can also drop in Pushover, Pushbullet, ntfy.sh — whichever notification path you’ll actually see on your phone at 3 AM.

A bonus: see what’s about to fire

Once you have multiple cron jobs spread across a server, “what’s running tonight?” gets harder. systemd-analyze has an answer for systemd timers; for cron, the easy command is:

# Show all cron entries plus their next-run times:
sudo apt install cronic        # (if not already)
crontab -l | crontab-next

# Or with the venerable:
grep -hE -v '^#|^$' /etc/cron.d/* /etc/crontab 2>/dev/null

End-state: tail -f /var/log/cron-jobs.log for live debugging. Slack ping when anything fails. Logrotate keeps the log bounded. No mail. No silent failures. The whole setup is one wrapper script and a one-line cron entry per job.

Photo: Code editor with line numbers by Luis Gomes on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.