← Blog

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:

  1. Set the server to your local TZ (server only serves users in one country):

    sudo timedatectl set-timezone Asia/Ho_Chi_Minh
    
  2. 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.