Cron works locally, fails on the server — 6 reasons I've hit a hundred times
Missing PATH, different $HOME, silent logs, time-zone drift. Here's the 6-step checklist I run every time a cron job 'runs but doesn't run'.
The scenario: you write a Bash script, run it from your terminal — perfect. Drop it into crontab — silence. No output, no log, no error. Just… nothing happens.
Or worse: cron does run, but the output is wrong, or partial, or half-success-half-fail and nobody notices.
I’ve debugged this exact scenario at least 100 times across 8 years of freelance and ops work. Here’s the checklist I always walk through — in order, from most common to rare.
Reason 1: Cron’s PATH is nearly empty
This is the #1 trap. 90% of newcomers hit it.
When you SSH in, your shell loads ~/.bashrc, ~/.profile, /etc/profile → your PATH ends up populated: /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:.... node, python3, composer, aws, docker all resolve.
Cron doesn’t load any of those files. Default cron PATH is just:
/usr/bin:/bin
That’s why node script.js works in your terminal but cron says “command not found” — your node lives at /usr/local/bin/node or /opt/homebrew/bin/node.
Fix #1: full paths in crontab:
* * * * * /usr/local/bin/node /home/deploy/app/script.js
Fix #2 (cleaner): declare PATH at the top of the crontab:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
* * * * * node /home/deploy/app/script.js
Run which node in your shell as the same user, copy that path in.
Reason 2: $HOME isn’t where you think
Cron runs as the user who owns that crontab. If you crontab -e as root, cron runs with $HOME=/root. Your script reads ~/config/secrets.json → cron looks for /root/config/secrets.json → file not found.
Quick probe:
* * * * * echo "HOME=$HOME, USER=$USER, PWD=$PWD" >> /tmp/cron-debug.log
Wait a minute, open /tmp/cron-debug.log. Compare with echo $HOME in your SSH session — different values means this is the culprit.
Fix: use absolute paths everywhere in your script. Or change the user that runs cron (sudo crontab -u deploy -e instead of root).
Reason 3: Output disappears into the void
Cron emails output to the user by default. Most VPSes have no mail server set up → email lands in /var/spool/mail/<user> (or just gets dropped). You never see it.
The rule I always follow: every cron line must redirect both stdout and stderr to a file.
* * * * * /home/deploy/app/script.sh >> /var/log/myapp/cron.log 2>&1
>> file appends stdout, 2>&1 redirects stderr to where stdout is going. 2>&1 must come after >> file — flip the order and stderr still escapes.
Once it’s set, tail -f /var/log/myapp/cron.log is your only debug surface — and it’s enough.
Reason 4: The server’s time zone isn’t yours
Most rented VPSes default to UTC. In Vietnam, UTC = local - 7. You set 0 8 * * * (8 AM) — cron runs at 3 PM local. Customer complains “the morning report comes in the afternoon”.
Check it:
timedatectl
# or older
cat /etc/timezone
date
Two ways to handle:
-
Set the server to your local TZ (server only serves users in one country):
sudo timedatectl set-timezone Asia/Ho_Chi_Minh -
Keep server on UTC, write cron in UTC (real production, multi-tz users):
0 1 * * *to fire at 8 AM Vietnam.
Option 2 is cleaner long-term but requires the team to remember “server is UTC”. Small teams: option 1. Real SaaS: option 2.
Reason 5: User doesn’t have permission
Crontab comes in two flavors:
- User crontab:
crontab -e— runs as that user. - System crontab:
/etc/crontab,/etc/cron.d/*,/etc/cron.daily/*— has an extra “user” column before the command.
Common trap: copying a command from system crontab into a user crontab while forgetting it needs root to write /var/log, or bind port <1024, or read /etc/shadow.
Test by running sudo -u <user> /path/to/script.sh — exactly how cron will run it. Failure means permissions.
Fix: either move it to root’s crontab, or rewrite the script to not need elevated rights (e.g. chmod /var/log/myapp/ so deploy can write).
Reason 6: Overlapping runs
A * * * * * cron fires every minute. If a run takes 90 seconds, the next run starts at second 60 — two instances racing. For DB-touching jobs, you corrupt data; for file syncs, you hit lock conflicts.
Fix with flock:
* * * * * /usr/bin/flock -n /tmp/myjob.lock /home/deploy/app/script.sh >> /var/log/myapp/cron.log 2>&1
flock -n tries to lock /tmp/myjob.lock. Already locked → exits immediately (no wait). So if the previous run hasn’t finished, the new run skips. Simple, no extra dependencies.
60-second debug checklist
When cron stays silent, I walk this order:
# 1. Is cron even running?
sudo systemctl status cron # Debian/Ubuntu
sudo systemctl status crond # CentOS/RHEL
# 2. What's in the crontab?
crontab -l
# 3. What does cron's log say?
sudo tail -50 /var/log/syslog | grep CRON # Ubuntu
sudo tail -50 /var/log/cron # CentOS
# 4. Probe the environment
echo '* * * * * env > /tmp/cron-env.log' | crontab -
sleep 70 && cat /tmp/cron-env.log
# 5. Diff against your login env:
env | sort > /tmp/login-env.log
diff /tmp/cron-env.log /tmp/login-env.log
Steps 4–5 are the nuke — diffing cron’s env against your login env tells you exactly which variable is missing or different. 95% of cases I fix with just those two steps.
My current cron template
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
# m h dom mon dow command
# Database backup at 2 AM VN time (= 19:00 UTC the day before)
0 19 * * * /usr/bin/flock -n /tmp/db-backup.lock /home/deploy/app/scripts/backup.sh >> /var/log/myapp/backup.log 2>&1
# Morning report at 8 AM VN (= 1:00 UTC)
0 1 * * * /home/deploy/app/scripts/morning-report.sh >> /var/log/myapp/report.log 2>&1
The first three lines (SHELL, PATH, MAILTO) are non-negotiable. MAILTO="" mutes the broken email path (we redirect to file anyway). SHELL=/bin/bash lets you use bash syntax in commands (default cron uses /bin/sh).
Closing
Cron is one of the oldest, simplest Unix tools — and it’s full of hidden traps. Most bugs aren’t in the script you wrote; they’re in the environment around it: PATH, HOME, USER, TZ, output redirect, locking.
Nail those six reasons and you’ll handle 95% of cron mysteries. The remaining 5% is usually SELinux or AppArmor blocking — but that’s a different post.