Error Medic

Cron Not Working: Fix Permission Denied, 504 Timeouts & Silent Crashes

Cron job not working? Diagnose and fix permission denied errors, 504 timeouts, and silent crashes with exact commands—covers PATH, env vars, and log analysis.

Last updated:
Last verified:
2,244 words
Key Takeaways
  • Root cause #1 — Environment mismatch: cron runs in a stripped environment with no PATH, HOME, or shell aliases; scripts that work interactively fail because binaries like python3, node, or docker can't be found.
  • Root cause #2 — Permission denied: the cron daemon user (often root or your login user) lacks execute permission on the target script, read permission on a file the script touches, or write permission on a log/tmp directory.
  • Root cause #3 — 504 Gateway Timeout: when a cron job triggers an HTTP endpoint (webhook, Laravel scheduler, WordPress cron), the web server kills the connection before the job finishes; fix by raising fastcgi_read_timeout or running the job directly in the shell.
  • Root cause #4 — Silent crash: by default cron discards stderr; add MAILTO or redirect output to a file so you can see the actual error message.
  • Quick fix summary: always use absolute paths, declare environment variables at the top of the crontab, redirect output with >> /var/log/myjob.log 2>&1, and verify with journalctl -u cron --since '5 minutes ago'.
Fix Approaches Compared
MethodWhen to UseTime to ApplyRisk
Use absolute paths everywhere in the scriptCommand not found errors; PATH-related failures5 minLow — no system changes
Declare environment variables in crontab headerScript needs HOME, LANG, JAVA_HOME, NVM_DIR, etc.5 minLow — crontab-scoped only
Wrap script in a sourcing shell scriptComplex env setup (nvm, pyenv, conda, rvm)10 minLow — extra file to maintain
Enable MAILTO and inspect email/syslogSilent crash; unknown error; no log output2 minNone — read-only diagnostic
Fix file/directory permissions (chmod/chown)Permission denied on script or dependent files5 minMedium — verify ownership before changing
Raise Nginx fastcgi_read_timeout504 on HTTP-triggered cron (WordPress, Laravel)10 min + reloadLow — only affects that location block
Migrate to systemd timer unitRecurring crashes, need resource limits, audit trail30 minMedium — replaces cron entirely for that job

Understanding Why Cron Jobs Fail

Cron is deceptively simple on the surface—a time-based scheduler that fires shell commands—but it trips up experienced engineers because it does not inherit your interactive shell environment. When you log in via SSH, your shell sources /etc/profile, ~/.bashrc, ~/.bash_profile, and any tool-specific hooks (nvm, pyenv, rbenv). Cron does none of that. It starts a bare /bin/sh with a minimal environment containing only HOME, LOGNAME, SHELL, and a skeletal PATH of /usr/bin:/bin.

This single fact explains the majority of cron failures. The fix almost always involves either making your script self-sufficient (absolute paths, explicit env var exports) or giving cron the environment it needs at the top of the crontab.


Step 1: Check the Logs First

Before touching anything, read what cron actually recorded.

On systemd-based systems (Ubuntu 16.04+, Debian 9+, RHEL 7+):

journalctl -u cron --since "1 hour ago" --no-pager
# or on Red Hat / CentOS where the unit is named crond:
journalctl -u crond --since "1 hour ago" --no-pager

On older syslog systems:

grep CRON /var/log/syslog | tail -50
grep CRON /var/log/cron | tail -50   # Red Hat / CentOS path

Look for two classes of messages: (username) CMD (command) tells you cron fired the job; FAILED (doasexecute) or permission denied tells you the execution itself failed. If there are no CMD entries at all for your expected schedule, the crontab entry itself is broken—check syntax (missing newline at end of file is a classic trap).


Step 2: Capture All Output — Fix the Silent Crash

Cron's default behavior is to email output to the job owner (the MAILTO variable). On servers with no local MTA this silently discards output. The fastest debugging change you can make is redirecting to a log file:

# Bad — stderr disappears
*/5 * * * * /opt/scripts/backup.sh

# Good — both stdout and stderr captured
*/5 * * * * /opt/scripts/backup.sh >> /var/log/backup-cron.log 2>&1

Alternatively, set MAILTO at the top of your crontab to send output to a real address or to an alias that pipes into a log aggregator:

MAILTO=ops@example.com
*/5 * * * * /opt/scripts/backup.sh

Once you have output, the actual error message will usually make the fix obvious.


Step 3: Fix PATH and Environment Variable Errors

The single most common non-trivial error message in cron logs is:

/bin/sh: 1: python3: not found
/bin/sh: node: command not found
/bin/sh: /opt/scripts/run.sh: not found

Option A — Declare PATH at the top of crontab:

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/deploy/.nvm/versions/node/v20.11.0/bin
HOME=/home/deploy
SHELL=/bin/bash
*/5 * * * * deploy /opt/scripts/backup.sh

Option B — Source your profile inside the script:

#!/bin/bash
source /home/deploy/.bashrc
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
npm run build

Option C — Use absolute paths everywhere:

#!/bin/bash
/usr/bin/python3 /opt/scripts/process.py \
  --config /etc/myapp/config.yml \
  >> /var/log/myapp-cron.log 2>&1

Find the absolute path of any binary with which python3 or type -a python3.


Step 4: Fix Permission Denied Errors

Exact error message you'll see:

/bin/sh: /opt/scripts/backup.sh: Permission denied

or inside a Python/Node script:

PermissionError: [Errno 13] Permission denied: '/var/data/output.csv'

The cron job runs as a specific user. Check who owns and can execute the script:

# Check script permissions
ls -la /opt/scripts/backup.sh
# Output: -rw-r--r-- 1 root root 1234 Jan 01 00:00 backup.sh
# Problem: no execute bit

# Fix execute permission
chmod +x /opt/scripts/backup.sh

# If cron runs as user 'deploy' but file is owned by root:
chown deploy:deploy /opt/scripts/backup.sh

# Fix a directory the script writes to:
chown -R deploy:deploy /var/data/
chmod 755 /var/data/

For system crontabs (/etc/cron.d/ or /etc/crontab), verify the username field is correct:

# /etc/cron.d/backup — field 6 is the username
*/5 * * * * deploy /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

If your script uses sudo internally, add the required rule to /etc/sudoers via visudo:

deploy ALL=(ALL) NOPASSWD: /usr/bin/rsync

Step 5: Fix Cron 504 Gateway Timeout

This error surfaces when a web server (Nginx + PHP-FPM, Nginx + uWSGI, Apache + mod_fcgid) acts as a proxy to a long-running PHP/Python/Ruby script and kills the connection before it finishes. You'll see it in the web server error log:

2025/01/15 03:00:12 [error] 1234#1234: *567 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 127.0.0.1, server: example.com, request: "GET /wp-cron.php?doing_wp_cron HTTP/1.1", upstream: "fastcgi://127.0.0.1:9000"

Fix A — Raise Nginx timeout for that location:

location ~ ^/wp-cron\.php$ {
    fastcgi_read_timeout 300;   # was 60
    fastcgi_send_timeout 300;
    include fastcgi_params;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

Then nginx -t && systemctl reload nginx.

Fix B (WordPress) — Disable HTTP cron and run it directly: In wp-config.php:

define('DISABLE_WP_CRON', true);

Then add a real cron entry:

*/5 * * * * /usr/bin/php /var/www/html/wp-cron.php >> /var/log/wp-cron.log 2>&1

Fix C (Laravel scheduler) — Run via artisan, not HTTP:

* * * * * deploy /usr/bin/php /var/www/laravel/artisan schedule:run >> /dev/null 2>&1

Step 6: Simulate the Cron Environment Interactively

Before wasting another 5-minute cron cycle, reproduce the exact cron environment in your terminal:

# Run a command exactly as cron would
env -i HOME=/home/deploy LOGNAME=deploy \
    PATH=/usr/bin:/bin \
    SHELL=/bin/sh \
    /bin/sh -c '/opt/scripts/backup.sh'

# Or use su to switch to the cron user
su -s /bin/sh -c '/opt/scripts/backup.sh' deploy

If the command fails here, you've reproduced the bug without waiting for the scheduler. Fix it until this command succeeds, then cron will work too.


Step 7: Validate Crontab Syntax

A common silent failure is a malformed crontab entry—cron ignores lines with invalid time specs and may silently drop the job.

# List current user's crontab
crontab -l

# Edit safely (validates on save)
crontab -e

# Check the system-wide crontab files
cat /etc/crontab
ls -la /etc/cron.d/

# Common syntax traps:
# 1. Missing newline at end of file (job silently not scheduled)
# 2. Using @reboot with no absolute path
# 3. Forgetting the username field in /etc/cron.d/ files

Use crontab.guru to verify your time expression before deploying.


Step 8: Consider Systemd Timers for Persistent Failures

If cron keeps causing grief, systemd timers offer better observability:

# View all timer status
systemctl list-timers --all

# Check a specific timer's last run
systemctl status myapp-backup.timer

# View job output
journalctl -u myapp-backup.service --since today

Systemd timers support OnFailure= hooks, resource limits via cgroups, and full journald integration—making post-mortem analysis far easier than vanilla cron.

Frequently Asked Questions

bash
#!/usr/bin/env bash
# cron-diagnose.sh — run this as the same user cron uses
# Usage: bash cron-diagnose.sh /path/to/your/script.sh

SCRIPT="${1:-/path/to/your/script.sh}"
CRON_USER="$(whoami)"

echo "=== 1. Cron service status ==="
systemctl is-active cron 2>/dev/null || systemctl is-active crond 2>/dev/null || echo "cron not found via systemctl"

echo ""
echo "=== 2. Recent cron log entries ==="
journalctl -u cron -u crond --since "1 hour ago" --no-pager 2>/dev/null \
  || grep CRON /var/log/syslog 2>/dev/null | tail -30 \
  || grep CRON /var/log/cron 2>/dev/null | tail -30

echo ""
echo "=== 3. Current crontab ==="
crontab -l 2>&1

echo ""
echo "=== 4. System-wide cron entries ==="
cat /etc/crontab 2>/dev/null
ls -la /etc/cron.d/ 2>/dev/null

echo ""
echo "=== 5. Script permissions ==="
ls -la "$SCRIPT"
stat "$SCRIPT"

echo ""
echo "=== 6. Simulate bare cron environment ==="
echo "Running: env -i HOME=$HOME LOGNAME=$CRON_USER PATH=/usr/bin:/bin SHELL=/bin/sh /bin/sh -c '$SCRIPT'"
env -i HOME="$HOME" LOGNAME="$CRON_USER" \
    PATH=/usr/bin:/bin \
    SHELL=/bin/sh \
    /bin/sh -c "$SCRIPT" 2>&1
EXIT_CODE=$?
echo "Exit code: $EXIT_CODE"

echo ""
echo "=== 7. Which binaries are missing in bare PATH ==="
for bin in python python3 node npm php ruby java docker; do
  FULL=$(which $bin 2>/dev/null)
  if [ -n "$FULL" ]; then
    echo "  Found: $bin -> $FULL"
  else
    echo "  MISSING: $bin (not in \$PATH under cron)"
  fi
done

echo ""
echo "=== 8. Check for 504-related Nginx errors ==="
grep 'upstream timed out' /var/log/nginx/error.log 2>/dev/null | tail -10 \
  || echo "No Nginx error log found at default path"

# --- Quick fixes cheatsheet ---
# Fix missing execute bit:
#   chmod +x /path/to/script.sh
#
# Fix wrong ownership:
#   chown deploy:deploy /path/to/script.sh
#
# Capture all cron output going forward:
#   Edit crontab: */5 * * * * /path/to/script.sh >> /var/log/myjob.log 2>&1
#
# Set PATH in crontab:
#   Add at top: PATH=/usr/local/bin:/usr/bin:/bin
#
# Fix WordPress 504:
#   Add to wp-config.php: define('DISABLE_WP_CRON', true);
#   Add crontab: */5 * * * * php /var/www/html/wp-cron.php
#
# Fix Nginx fastcgi timeout:
#   fastcgi_read_timeout 300;
#   fastcgi_send_timeout 300;
#   Then: nginx -t && systemctl reload nginx
E

Error Medic Editorial

Error Medic Editorial is a team of senior DevOps engineers and SREs with 10+ years of production Linux experience across cloud and bare-metal environments. We write diagnostic-first troubleshooting guides based on real incidents, not documentation rewrites.

Sources

Related Guides