Your script runs perfectly when you type it at the prompt. The same script, scheduled in cron, does nothing, or worse, half of something. Nine times out of ten this is not a cron bug. It is an environment bug, and cron is only where it surfaces.
When you run a script by hand you inherit the environment of an interactive login shell: a fat PATH, your exported variables, a sourced .bashrc or .profile, a real HOME, a working directory you chose, and probably a live SSH agent. Cron gives your script almost none of that. It starts your job in a deliberately minimal environment, and everything your script silently assumed about that environment is now wrong. The fix is not to fight cron. It is to stop assuming.
What cron actually gives you
A cron job runs in a non-interactive, non-login shell. That single fact explains most of the failures. A non-login shell does not read /etc/profile, ~/.bash_profile or ~/.profile. A non-interactive shell does not read ~/.bashrc either. So every alias, every export, every PATH extension you keep in those files is simply absent.
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
The two variables that bite hardest are PATH and SHELL. Classic cron sets a bare default PATH of /usr/bin:/bin unless you override it. Anything installed under /usr/local/bin, /opt, a language version manager such as nvm, pyenv or rbenv, or a per-user tools directory, will not be found. Your script calls node, or aws, or docker, and the shell reports “command not found” into a log you are not reading. Cron also runs commands through /bin/sh by default, not bash, unless you set SHELL in the crontab. On Debian and Ubuntu /bin/sh is dash, which does not understand bashisms such as [[ ]], arrays, or source.
The rest of the missing environment follows the same pattern. HOME may be set to the wrong directory or, in some setups, be effectively unusable, so tools that look for config under ~/.aws or ~/.config read nothing. The working directory is HOME, not the directory the script lives in, so every relative path breaks. There is no terminal attached, so anything that expects a TTY misbehaves. And there is no SSH agent, so a git pull over SSH that works for you fails for cron.
See the difference before you fix it
Do not guess at what is missing. Capture cron’s actual environment and compare it to yours. Add a one-line diagnostic job that dumps the environment cron hands you, wait for it to run, then read the file.
# Temporary diagnostic line in your crontab (crontab -e):
* * * * * /usr/bin/env > /tmp/cron-env.txt 2>&1
# Then, at your own prompt, compare:
diff <(env | sort) <(sort /tmp/cron-env.txt)
The left column is what you have; the right is what cron has. The gaps in PATH, the missing exports, the different HOME and PWD are your entire bug list, laid out in front of you. Remove the diagnostic line once you have the file.
The fixes, in order of reliability
Work from most robust to least. The most robust fix is to depend on nothing you have not set explicitly.
- Set the environment at the top of the crontab. Cron lets you declare variables above the schedule lines. Set
SHELLand a fullPATHthere so every job inherits them. - Use absolute paths to every binary. Not
python3but/usr/bin/python3; notawsbut/usr/local/bin/aws. Runcommand -v toolnameat your prompt to find the real path. - Redirect stderr as well as stdout to a log. Silent failure is the enemy. Capture both streams to a file you can read.
- Source what you genuinely need, explicitly. If the job truly requires
nvmor a virtualenv, source it inside the script rather than assuming a login shell did it for you. - Make the script directory-independent.
cdto a known absolute directory at the top, or resolve paths relative to the script’s own location.
Here is a crontab and a script wrapper that apply all of it. Note the escaped % in the timestamp: an unescaped % in a crontab command is treated as a newline, which is a classic cron gotcha in its own right.
# --- crontab (crontab -e) ---
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/home/deploy
MAILTO=ops@example.com
# Run the backup at 02:15 daily; capture stdout and stderr with a timestamp.
15 2 * * * /home/deploy/bin/backup.sh >> /var/log/backup-$(date +%Y%m%d).log 2>&1
#!/usr/bin/env bash
# backup.sh - written to survive cron's minimal environment
set -Eeuo pipefail
# Resolve our own directory so relative paths never depend on PWD.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Absolute binaries; do not trust an inherited PATH.
PG_DUMP=/usr/bin/pg_dump
AWS=/usr/local/bin/aws
# Fail loudly, with context, if a required tool is missing.
for bin in "$PG_DUMP" "$AWS"; do
[[ -x "$bin" ]] || { echo "Missing required binary: $bin" >&2; exit 127; }
done
"$PG_DUMP" --no-owner appdb | gzip > /var/backups/appdb.sql.gz
"$AWS" s3 cp /var/backups/appdb.sql.gz s3://example-backups/
The set -Eeuo pipefail line matters more under cron than anywhere else. Because nobody is watching the terminal, a script that ploughs on after a failed command will happily report success while doing nothing useful. Failing fast turns a silent non-event into a logged, alertable error.
Reading the logs when it still fails
If the job does not even start, the problem is cron itself, not your script, and cron records that separately. On systemd-based distributions the cron daemon logs to the journal. View its entries with:
# Daemon-level cron activity (what cron tried to run, and syntax errors)
journalctl -t CRON --since "today"
journalctl _COMM=cron --since "1 hour ago"
# On distributions still using rsyslog, the same lines land here:
grep CRON /var/log/syslog
Those entries tell you whether cron parsed and attempted your line at all. If you see the attempt but no output, the failure is inside the script and your redirected log holds the answer. If you do not see the attempt, the crontab syntax, the user it was installed for, or a stray unescaped % is the culprit.
The wider lesson is a governance one, not a bash one. A scheduled job that fails silently is an undetected control failure, and in a regulated estate that is exactly the kind of gap an auditor finds before you do. The same discipline that makes scripts survive cron — explicit environment, captured output, fail-fast behaviour — is what makes any automated baseline actually run rather than merely appear to. Treat a scheduled backup or reconciliation with the same seriousness as a deploy: instrument it, alert on it, and fold its failures into the operational signals you already track for change and recovery. And if that job moves credentials around, make sure the secrets it reads are scoped and rotatable rather than baked into a script cron runs unattended at two in the morning.
Cron is not mysterious. It is honest: it gives your script the bare minimum and refuses to paper over what you assumed. Make every assumption explicit, and the script that worked by hand will work at 02:15 too.
Build and rescue work
Hands-on delivery of this kind is handled by Sixteen Pillars Studio.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.