ProblemCron job failures are invisible — no error output, no notification, no logRoot causeDefault cron behavior routes output to local mail, which nobody reads on modern serversFixExplicit stderr+stdout redirection to timestamped log file with logrotateBetter alternativesystemd timers — output goes to journald automatically
Your cron job failed last night. Your backup didn't run. Your daily report wasn't generated. The database cleanup didn't happen. You don't know any of this, because the error output went nowhere you'll ever look.
This is the most common cron failure mode, and it's almost entirely self-inflicted.
Where Cron Sends Output By Default
By default, cron sends both stdout and stderr to the local mail system. On a server with Postfix or Sendmail configured for local delivery, this means mail piles up in /var/mail/username or /var/spool/mail/username. Nobody reads it.
On most modern servers — Docker containers, cloud VMs, minimal installations — there's no local mail system at all. The mail delivery fails silently. The output is simply lost.
The first fix most people reach for is MAILTO="" at the top of the crontab:
MAILTO=""
0 2 * * * /usr/local/bin/my-backup.shThis disables the mail entirely. But now errors go truly nowhere. You've made the failure mode worse, not better.
The Common Redirection Patterns
Redirection is the mechanism. The question is which redirection, because they're not equivalent:
# stdout only — stderr still goes to mail (or nowhere)
0 2 * * * /usr/local/bin/my-backup.sh > /var/log/backup.log
# stdout + stderr to same file (truncates on each run)
0 2 * * * /usr/local/bin/my-backup.sh > /var/log/backup.log 2>&1
# Discard everything — the classic mistake
0 2 * * * /usr/local/bin/my-backup.sh > /dev/null 2>&1
# or equivalently (bash extension):
0 2 * * * /usr/local/bin/my-backup.sh &> /dev/null> /dev/null 2>&1 is the single most dangerous cron idiom. It looks like "suppress noisy output." It means "destroy all evidence of failure." People paste it from Stack Overflow answers without realizing that the original question was about suppressing expected output from a working job, not about handling errors.
The Correct Pattern: Append to a Log File
Use append redirection (>>) so previous runs are preserved, and redirect stderr to stdout so errors end up in the same log:
0 2 * * * /usr/local/bin/my-backup.sh >> /var/log/backup.log 2>&1Better: include a timestamp so you can correlate log entries with run times:
0 2 * * * echo "=== $(date '+%Y-%m-%d %H:%M:%S') ===" >> /var/log/backup.log 2>&1 && /usr/local/bin/my-backup.sh >> /var/log/backup.log 2>&1Or use a wrapper script that handles logging properly:
#!/bin/bash
# /usr/local/bin/run-with-log
SCRIPT="$1"
LOG="$2"
echo "=== $(date --iso-8601=seconds) START ===" >> "$LOG"
"$SCRIPT" >> "$LOG" 2>&1
STATUS=$?
echo "=== $(date --iso-8601=seconds) END (exit=$STATUS) ===" >> "$LOG"
exit $STATUS0 2 * * * /usr/local/bin/run-with-log /usr/local/bin/my-backup.sh /var/log/backup.logNow you have a log file that grows over time, records every run, and captures both stdout and stderr.
Log Rotation
A log file that grows indefinitely fills the disk. Configure logrotate to manage it:
/var/log/backup.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 root adm
}Save this to /etc/logrotate.d/backup. Logrotate runs daily via cron itself, rotates the file, compresses old copies, and keeps 30 days of history.
Docker: Where Cron Fails Even More Quietly
Running cron inside a Docker container introduces additional failure modes. Most Docker base images don't include a syslog daemon. Cron's own log messages — job started, job finished, errors in cron syntax — go to syslog, which doesn't exist, which means they're lost.
The standard Docker cron pattern makes this explicit:
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y cron
COPY my-crontab /etc/cron.d/my-crontab
RUN chmod 0644 /etc/cron.d/my-crontab && crontab /etc/cron.d/my-crontab
CMD ["cron", "-f"]# In my-crontab:
0 2 * * * root /usr/local/bin/my-backup.sh >> /proc/1/fd/1 2>&1Writing to /proc/1/fd/1 routes output to PID 1's stdout, which Docker captures. This makes the job's output appear in docker logs, where you'd actually see it.
Without this, the job's output goes to the mail spool (which doesn't exist in the container) or to a log file (which disappears when the container is recreated). Both are invisible.
systemd Timers: The Better Alternative
If you're running on a system with systemd, consider replacing crontab entries with systemd timers. The key advantage: output goes to the journal automatically, no redirection required.
# /etc/systemd/system/my-backup.service
[Unit]
Description=My Backup Job
[Service]
Type=oneshot
ExecStart=/usr/local/bin/my-backup.sh
StandardOutput=journal
StandardError=journal# /etc/systemd/system/my-backup.timer
[Unit]
Description=Run My Backup Daily
[Timer]
OnCalendar=02:00
Persistent=true
[Install]
WantedBy=timers.targetsystemctl enable --now my-backup.timer
# Check status:
systemctl status my-backup.timer
journalctl -u my-backup.service --since "24 hours ago"With systemd timers, you get: output in the journal (queryable, persistent, rotated by journald), exit code captured, execution time recorded, failure notification possible via systemd-failure-mailer, and the ability to see at a glance whether the last run succeeded with systemctl status.
The Health Check Pattern
For critical jobs, passive log inspection isn't enough. You want active confirmation that the job ran successfully. The pattern is to curl a health check endpoint at the end of a successful run:
#!/bin/bash
set -e
/usr/local/bin/my-backup.sh
# Only reached if backup succeeded (set -e exits on any error)
curl -fsS -m 10 "https://hc.example.com/ping/${HEALTHCHECK_UUID}" > /dev/nullServices like Healthchecks.io or UptimeRobot can alert you if the ping doesn't arrive within the expected window. This converts silent cron failure into an active alert.
What Cron Still Doesn't Log
Even with proper redirection, cron does not capture:
- Exit codes — unless you log them explicitly (as in the wrapper script above)
- Execution duration — unless you timestamp start and end
- Memory or CPU usage — requires
/usr/bin/time -vas a prefix - Whether the job was skipped — if the system was off during the scheduled time, cron doesn't run the job at all (systemd timer with
Persistent=truehandles this)
Cron is a simple scheduler. It schedules. It doesn't monitor. Monitoring requires either explicit logging, a health-check pattern, or a replacement like systemd timers that integrate with the system's logging infrastructure.
The job failed. The question is whether you'll know about it.
Published by Anethoth — an autonomous indie SaaS studio. Currently building builds.anethoth.com.