The short answer: Cron does not read .bashrc or .bash_profile and gives the job a minimal environment, usually just PATH=/usr/bin:/bin plus HOME and SHELL. Anything your interactive shell provided is missing. Reproduce it with env -i /bin/sh -c "/path/to/script" and the failure appears immediately.
A backup script runs perfectly by hand and produces nothing under cron, which has been firing it on schedule for a week. The script depends on a PATH entry and an environment variable that exist in an interactive shell and nowhere else, because cron does not read .bashrc. This replay shows the job succeeding by hand, failing in a scrubbed environment, the two errors in the log, and the fix that makes it work anywhere.
The box is ops-02 with a nightly backup job. Run the script in a normal shell and it works: it creates a timestamped directory, copies the data, compresses it, and prints its confirmation. This is what everyone tests, and it is why the problem survives for a week.
The schedule is correct, cron is running, and the destination is empty. Then the script is run the way cron actually runs it, with a scrubbed environment, and it fails instantly. Same script, same box, same user, different environment.
The log holds two errors: a variable that was empty, and a command that was not found. Both exist in an interactive shell. Neither exists in cron's.
Cron does not start a login shell. It does not read /etc/profile, .bash_profile, or .bashrc, because those are for interactive sessions and a scheduled job is not one. What it provides is close to nothing: a short PATH, usually just /usr/bin and /bin, plus HOME, LOGNAME, and SHELL.
Everything else a human session accumulates is simply absent. Tools installed somewhere non-standard are not on the PATH. Variables exported in a dotfile do not exist. Anything a version manager or a virtualenv activation script set up never happened.
This is the entire class of bug. The script never depended on itself, it depended on the shell that usually calls it, and cron is not that shell.
Reproduce it deliberately instead of waiting for the schedule. Running the script under env -i with a minimal PATH gives you cron's environment on demand, and the failure appears immediately and repeatably.
That single technique is the whole skill here. Testing a cron job by running it in your own shell tests the wrong thing, and it will keep passing while the scheduled run keeps failing.
The other half is capturing output. A cron job with no redirect sends output to mail that nobody reads, so the errors that explain everything are invisible. Every cron line should redirect to a log.
root@ops-02:~# echo "interactive: $BACKUP_ROOT" interactive: /srv/backups root@ops-02:~# env -i /bin/sh -c 'echo "cron sees: [$BACKUP_ROOT]"' cron sees: [] root@ops-02:~# env -i /bin/sh -c 'PATH=/usr/bin:/bin; command -v pigz' cron: pigz not found
env -i /bin/sh -c '/path/to/script.sh': Run a job in a cron-like empty environmentenv -i PATH=/usr/bin:/bin sh -c "command -v TOOL": Check whether cron could find a tooljournalctl -u cron -n 50: Confirm cron actually fired the job*/5 * * * * user /path/script >> /var/log/x.log 2>&1: Always redirect, or errors go to unread mailset -euo pipefail: Make the script fail loudly instead of silentlysystemd-run --on-active=1m --unit=test /path: Try it as a timer, which logs to the journalWatch the full replay, browse all recorded incidents, or fix a broken server yourself in the break/fix challenges.