Cron job logging sounds straightforward until you check what cron actually records. It logs that a job started. It doesn't log what the job did, whether it worked, or what it printed. That distinction trips up every sysadmin at least once: you check /var/log/syslog, see the entry, assume everything is fine, and three days later discover the backup script has been throwing a connection error to nobody since Tuesday.
This guide covers both sides: the system-level records cron writes automatically, and the cron output logging you have to set up yourself.
Where cron stores its logs by default
Cron doesn't keep its own log file. It sends events to syslog under the cron facility, and your distro's syslog daemon decides where those end up.
# Ubuntu / Debian
grep CRON /var/log/syslog | tail -10
# RHEL / CentOS / Fedora
tail -20 /var/log/cron
# macOS
grep cron /var/log/system.log | tail -10
A typical entry on Ubuntu looks like this:
Oct 16 03:00:01 web1 CRON[48392]: (deploy) CMD (cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1)
That tells you three things: timestamp, user, and the command that ran. It doesn't tell you the exit code, the runtime, or whether the script produced output. For the system, "ran" and "succeeded" are the same event. They aren't. And these crontab logs won't tell you the difference.
systemd journal: the other place to look
On systemd-based distributions (most modern Linux), cron events also land in the journal:
# Debian / Ubuntu
journalctl -u cron --since "1 hour ago"
# RHEL / Fedora
journalctl -u crond --since "1 hour ago"
journalctl has better filtering than grep. You can narrow by time range, priority level, or combine with other unit logs. On servers where syslog has been disabled in favor of journald-only logging, this is your only source.
When cron logging is turned off
Some minimal server images ship with cron logging disabled in rsyslog. If grep CRON /var/log/syslog returns nothing, check /etc/rsyslog.d/50-default.conf (Ubuntu) or /etc/rsyslog.conf (RHEL):
# Look for this line — if it's commented out, cron events go nowhere
cron.* /var/log/cron.log
Uncomment it (or add it), then restart rsyslog:
sudo systemctl restart rsyslog
Now cron events land in a dedicated cron job log file at /var/log/cron.log, separate from the rest of syslog. Easier to read, easier to rotate.
Cron output logging: capturing what cron skips
System logs record "cron ran this command." Your job's actual output (errors, warnings, row counts, stack traces) goes nowhere unless you redirect it.
The basic redirect everyone knows:
0 3 * * * /usr/bin/php /var/www/app/backup.php >> /var/log/backup.log 2>&1
The >> appends. 2>&1 merges stderr into the same file. Without the stderr redirect, PHP fatal errors and Python tracebacks vanish — cron tries to email them to the system user, and local mail delivery hasn't worked on most servers since 2015.
A slightly better version adds a timestamp:
0 3 * * * /usr/bin/php /var/www/app/backup.php 2>&1 | while IFS= read -r line; do echo "$(date '+\%Y-\%m-\%d \%H:\%M:\%S') $line"; done >> /var/log/backup.log
That timestamps every line of output. Overkill for most jobs, but worth it when a script prints 200 lines and you need to know which one took 40 seconds.
Separate stdout and stderr
Sometimes you want errors in a different file than normal output. A nightly import that processes 50,000 rows will print progress to stdout and exceptions to stderr. Mixing them makes the log hard to parse.
0 3 * * * /var/www/app/import.sh >> /var/log/import.log 2>> /var/log/import-errors.log
Check import-errors.log first. If it's empty, the job ran clean. If it's not, you don't have to dig through 50,000 lines of "Processed row 41,293" to find the stack trace.
Log rotation: the file that ate the disk
I once SSH'd into a client's server because monitoring showed disk usage at 98%. The culprit: a 4.7 GB log file from a cron job running every minute that had been appending output for 11 months. Nobody thought to rotate it because nobody remembered it existed.
Logrotate handles this. Create /etc/logrotate.d/cron-jobs:
/var/log/backup.log /var/log/import.log /var/log/import-errors.log {
weekly
rotate 4
compress
missingok
notifempty
create 0640 root root
}
weekly rotates every 7 days. rotate 4 keeps 4 weeks of history. compress gzips old files. A month of logs for a daily backup script is rarely more than a few megabytes compressed.
For high-frequency jobs (every 5 minutes, every minute, or faster), switch to daily rotation with rotate 7. Or use maxsize 100M to trigger rotation when the file hits a size limit regardless of schedule.
Test your config before waiting a week to see if it works:
sudo logrotate -d /etc/logrotate.d/cron-jobs # dry run
sudo logrotate -f /etc/logrotate.d/cron-jobs # force rotate now
A logging wrapper that captures everything
Instead of adding redirects to every crontab line, wrap your jobs in a script that handles logging consistently. Here's one I've used across a handful of production servers:
#!/bin/bash
# /usr/local/bin/cronlog — wraps any cron command with structured logging
JOB_NAME="${1:?Usage: cronlog job-name command [args...]}"
shift
LOG_DIR="/var/log/cron-jobs"
LOG_FILE="$LOG_DIR/$JOB_NAME.log"
mkdir -p "$LOG_DIR"
START=$(date +%s)
START_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
echo "[$START_ISO] START $JOB_NAME" >> "$LOG_FILE"
OUTPUT=$("$@" 2>&1)
EXIT_CODE=$?
END=$(date +%s)
DURATION=$((END - START))
echo "[$START_ISO] END $JOB_NAME exit=$EXIT_CODE duration=${DURATION}s" >> "$LOG_FILE"
if [ $EXIT_CODE -ne 0 ]; then
echo "[$START_ISO] OUTPUT:" >> "$LOG_FILE"
echo "$OUTPUT" >> "$LOG_FILE"
fi
exit $EXIT_CODE
Usage in crontab:
0 3 * * * /usr/local/bin/cronlog nightly-backup /var/www/app/backup.sh
*/5 * * * * /usr/local/bin/cronlog queue-worker /usr/bin/php /var/www/app/artisan queue:work --stop-when-empty
Each job gets its own log file under /var/log/cron-jobs/. The wrapper records start time, end time, duration, and exit code. Output is only written on failure — keeps logs small for jobs that succeed 99% of the time.
Add a logrotate config for the whole directory:
/var/log/cron-jobs/*.log {
daily
rotate 14
compress
missingok
notifempty
}
Structured logging with JSON
Plain text logs work until you have 30 cron jobs across 5 servers and need to answer "which jobs failed last night?" Grep starts to hurt. Structured logs in JSON format let you pipe into jq, ship to a log aggregator, or query with any tool that understands JSON.
Modify the wrapper to output JSON:
#!/bin/bash
# /usr/local/bin/cronlog-json
JOB_NAME="${1:?Usage: cronlog-json job-name command [args...]}"
shift
LOG_FILE="/var/log/cron-jobs/structured.jsonl"
START=$(date +%s%N)
START_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
HOSTNAME=$(hostname -s)
OUTPUT=$("$@" 2>&1)
EXIT_CODE=$?
END=$(date +%s%N)
DURATION_MS=$(( (END - START) / 1000000 ))
printf '{"ts":"%s","host":"%s","job":"%s","exit":%d,"duration_ms":%d,"output":"%s"}\n' \
"$START_ISO" "$HOSTNAME" "$JOB_NAME" "$EXIT_CODE" "$DURATION_MS" \
"$(echo "$OUTPUT" | head -c 2000 | sed 's/"/\\"/g' | tr '\n' ' ')" \
>> "$LOG_FILE"
exit $EXIT_CODE
Now you can query failures across all jobs:
# All failures in the last 24 hours
jq 'select(.exit != 0)' /var/log/cron-jobs/structured.jsonl
# Slowest jobs
jq -r '[.job, .duration_ms] | @tsv' /var/log/cron-jobs/structured.jsonl | sort -t$'\t' -k2 -rn | head -10
# Failures for a specific job
jq 'select(.job == "nightly-backup" and .exit != 0)' /var/log/cron-jobs/structured.jsonl
The .jsonl extension (JSON Lines) means one JSON object per line — no commas between entries, no wrapping array. Append-friendly, grep-friendly, and every log shipper knows how to parse it.
Cron logging in Docker containers
Docker expects processes to log to stdout. Cron doesn't. It writes to syslog, which usually isn't running inside a container. This means docker logs shows nothing from your cron jobs even when they're running fine. We covered this in the Docker cron guide, and the same applies to Kubernetes CronJobs where logging goes through the pod's stdout.
The quick fix is redirecting to the container's stdout file descriptor:
# In your container's crontab
0 3 * * * /app/backup.sh > /proc/1/fd/1 2>/proc/1/fd/2
/proc/1/fd/1 is stdout of PID 1 (the container's main process). Output sent there shows up in docker logs. This works but feels like a hack because it is one.
A cleaner solution: Supercronic. It runs as PID 1, reads a standard crontab file, and sends all job output to stderr where Docker's logging driver picks it up. No syslog, no file descriptor tricks, no log files to rotate inside the container.
# docker-compose.yml
services:
cron:
build: .
command: ["supercronic", "/etc/crontab"]
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
The json-file logging driver with size limits handles rotation at the Docker level. No logrotate needed inside the container.
Sending cron logs to a centralized system
Here's the thing about log files on individual servers: they stop being useful past two or three machines. Searching across servers with SSH and grep gets old fast. This is where Cloud Cron and centralized monitoring start making more sense than self-managed crontabs.
If you're already running a log aggregator (ELK, Loki, Datadog, Papertrail), point your cron logs at it. With the JSON wrapper from the previous section, most shippers pick up .jsonl files automatically.
For Fluentd or Fluent Bit, add a tail input:
# fluent-bit.conf
[INPUT]
Name tail
Path /var/log/cron-jobs/structured.jsonl
Parser json
Tag cron.*
[OUTPUT]
Name loki
Match cron.*
Host loki.internal
Port 3100
Labels job=cron
For rsyslog forwarding (if you prefer plain text syslog):
# /etc/rsyslog.d/60-cron-forward.conf
cron.* @@logserver.internal:514
The @@ means TCP. Use @ for UDP. TCP is safer. UDP drops packets under load and nobody notices until the gap matters.
Cron job logging is not cron job monitoring
Logs tell you what happened after the fact. If nobody reads them, they're just disk usage. A cron job that fails at 3 AM on Saturday writes its error to a log file. You find it Monday morning. That's a 54-hour gap between failure and awareness.
Heartbeat monitoring closes that gap. After each successful run, the job pings a URL. If the ping doesn't arrive on schedule, you get an alert immediately, not when someone greps the logs.
The two work together, and understanding cron failure types helps you pick the right approach for each. Monitoring tells you THAT something broke. Logs tell you WHY. A good setup has both: the dead man's switch fires an alert to Slack or Telegram, you SSH in, check the log file, and the structured entry tells you the exit code, runtime, and first 2,000 characters of output.
With the cronlog wrapper, add a heartbeat ping on success:
#!/bin/bash
# Extended cronlog with heartbeat
JOB_NAME="${1:?}"
PING_URL="${2:?}"
shift 2
# ... same logging logic as before ...
OUTPUT=$("$@" 2>&1)
EXIT_CODE=$?
# ... log the result ...
if [ $EXIT_CODE -eq 0 ]; then
curl -fsS --max-time 10 "$PING_URL" > /dev/null 2>&1
fi
exit $EXIT_CODE
# Crontab
0 3 * * * /usr/local/bin/cronlog nightly-backup https://watchcron.com/ping/abc123 /var/www/app/backup.sh
Logs capture the detail. Monitoring captures the silence. The curl heartbeat guide covers the ping setup in depth.
Quick reference: cron log locations by OS
| OS / Distro | Default log location | Journal command |
|---|---|---|
| Ubuntu / Debian | /var/log/syslog | journalctl -u cron |
| RHEL / CentOS / Fedora | /var/log/cron | journalctl -u crond |
| Arch Linux | journal only | journalctl -u cronie |
| Alpine (Docker) | /var/log/cron | n/a (no systemd) |
| macOS | /var/log/system.log | n/a |
Filter cron entries from mixed logs with grep CRON /var/log/syslog. On RHEL, /var/log/cron already contains only cron entries. Use the cron expression cheat sheet to verify your schedule matches what the logs show, and check uptime reports for a historical view of job health.