Cron Job Logging: Where Output Goes and How to Capture It

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 / DistroDefault log locationJournal command
Ubuntu / Debian/var/log/syslogjournalctl -u cron
RHEL / CentOS / Fedora/var/log/cronjournalctl -u crond
Arch Linuxjournal onlyjournalctl -u cronie
Alpine (Docker)/var/log/cronn/a (no systemd)
macOS/var/log/system.logn/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.

---

Frequently asked

On Ubuntu and Debian, cron events go to /var/log/syslog. On RHEL, CentOS, and Fedora, they go to /var/log/cron. On systemd-based systems, you can also use journalctl -u cron (Debian) or journalctl -u crond (RHEL). These logs only record that a job ran, not its output or exit code.

Add >> /path/to/logfile.log 2>&1 at the end of your crontab entry. The >> appends output, and 2>&1 merges error output into the same file. Without the stderr redirect, errors from your script will be lost.

Three common causes: (1) cron logging is disabled in rsyslog — check /etc/rsyslog.conf for a commented-out cron.* line, (2) your job's output redirect uses > instead of >>, overwriting the file each run, or (3) the job itself produces no output because errors are being swallowed by a try/catch block.

Create a logrotate config at /etc/logrotate.d/cron-jobs specifying the log file path, rotation frequency (daily/weekly), and how many old copies to keep. Run logrotate -d /etc/logrotate.d/cron-jobs to test the config without rotating.

Logging records what happened — timestamps, output, errors — to a file you can read later. Monitoring actively watches for missing events and alerts you in real time when something fails. Logs tell you why it broke. Monitoring tells you that it broke. You need both for production cron jobs.