A script that works when you run it and fails under cron is failing because cron runs it in a different world: a nearly empty PATH, no environment variables from your profile, a home directory as the working folder, and no terminal. The script itself is fine. The fix is to stop assuming any of those things and, first, to capture the error you are currently not seeing.
Step 1: capture the output#
Cron mails output to the local user, which nobody reads. Redirect it somewhere you will:
*/5 * * * * /home/you/bin/backup.sh >> /home/you/backup.log 2>&1
Wait for the next run, then read the log. Nearly every mystery becomes a specific error at this point — and the error is usually one of the following.
grep CRON /var/log/syslog | tail # Debian/Ubuntu: confirms cron ran the job at all
journalctl -u cron --since "10 min ago" # systemd systems
Cause 1: PATH#
backup.sh: line 3: aws: command not found
backup.sh: line 5: python3: command not found
Cron’s default PATH is typically /usr/bin:/bin. Anything in /usr/local/bin, ~/.local/bin, a version manager’s folder, or a virtual environment is invisible. Two fixes, and use both:
# at the top of the crontab
PATH=/usr/local/bin:/usr/bin:/bin:/home/you/.local/bin
#!/bin/bash
# in the script: full paths for anything not in /usr/bin
/home/you/project/.venv/bin/python /home/you/project/run.py
Absolute paths in the script are the robust answer, because they work regardless of who or what runs it.
Cause 2: the profile never ran#
Cron does not read ~/.bashrc, ~/.profile or ~/.zshrc. Every export in them — API keys, JAVA_HOME, nvm’s setup, conda’s activation — is absent. Options:
# crontab: set what the script needs directly
API_KEY=abc123
0 2 * * * /home/you/bin/report.sh >> /home/you/report.log 2>&1
# or, in the script, source a file that contains only the variables
set -a
. /home/you/.env
set +a
Sourcing your whole .bashrc from a cron job is possible but fragile; many profiles exit early when not interactive. A small dedicated env file is cleaner.
Cause 3: the working directory#
Cron starts the job in the user’s home directory, not the script’s folder. A script that does cat config.yml or ./helper.sh finds nothing.
#!/bin/bash
cd "$(dirname "$0")" || exit 1 # work from the script's own folder
Or use absolute paths for every file. Or cd in the crontab line: cd /home/you/project && ./run.sh.
Cause 4: the shell#
Cron runs jobs with /bin/sh, which on Debian and Ubuntu is dash, not bash. A script with bash-only syntax — arrays, [[ ]], source — that is invoked as sh script.sh or lacks a shebang fails with syntax errors. Give the script a bash shebang and execute it directly, or set the shell in the crontab:
SHELL=/bin/bash
Cause 5: permissions and ownership#
chmod +x /home/you/bin/backup.sh
ls -l /home/you/bin/backup.sh
The script must be executable if called directly. And it runs as the user whose crontab it is in: a job in your crontab cannot write to root-owned locations, and a job in root’s crontab has a different home directory and PATH again. Check whose crontab you edited with crontab -l as that user.
Cause 6: percent signs and line endings#
In a crontab line, % is special: it means newline, and everything after the first one becomes standard input. A date format such as date +%Y-%m-%d in the crontab breaks silently. Escape them as \%, or move the command into the script where % is ordinary.
A script edited on Windows has carriage returns, and cron reports /bin/bash^M: bad interpreter. Run sed -i 's/\r$//' script.sh.
Cause 7: it needs a terminal or a display#
Anything that prompts for input hangs forever. Anything that opens a window fails with no display. Commands that check for a TTY may behave differently — some tools disable colour or progress bars, others refuse to run. Give tools non-interactive flags (-y, --batch, --no-input), and for SSH inside cron, use a key with no passphrase or an agent set up for the purpose.
A script that survives cron#
#!/bin/bash
set -euo pipefail # stop on error, unset variable, or failed pipe
export PATH=/usr/local/bin:/usr/bin:/bin
cd "$(dirname "$0")"
exec >> "$HOME/backup.log" 2>&1 # log everything, from here on
echo "=== $(date) start"
/usr/bin/rsync -a /home/you/data/ /mnt/backup/data/
/home/you/.venv/bin/python notify.py
echo "=== $(date) done"
Absolute paths, an explicit PATH, an explicit working directory, and its own log. A script written like this runs the same under cron, from a terminal, and from systemd.
Questions people ask#
Should I use systemd timers instead?
On systemd systems they offer logging, dependencies and easier environment handling. Cron is simpler and universal. Either works once the script does not assume a login environment.
Why does the job run but only partly?
One command failed silently and the script carried on. set -e stops at the first failure so the log shows where.
Does cron run if I am logged out?
Yes, for user crontabs on a running machine. It does not run jobs that were due while the machine was off; anacron handles that for daily jobs.
My job needs a virtual environment activated.
Do not activate; call the environment’s interpreter by full path, as in the example. Activation only edits PATH, and the full path makes it unnecessary.
Where to go next#
- Error handling in bash scripts — set -euo pipefail and traps.
- “bad interpreter” errors — the line-endings case.
- “command not found” but the file exists — PATH in depth.