Why your `cron` job runs in a different environment than your shell — and how to make it stop biting

You write a script. You run it from your shell — it works perfectly. You add it to crontab to run hourly — it silently fails. The script’s still there, the cron’s firing on schedule, but the actual command is producing nothing or producing something different than what your terminal showed. This is one of the oldest gotchas in Unix sysadmin and it gets every developer at least once.

The reason: cron runs your job in a stripped-down environment that’s almost nothing like your interactive shell. Different PATH. No shell aliases. Different working directory. Different umask. Sometimes a different shell entirely. Here’s why, what changes, and the small set of fixes that make this stop biting forever.

What’s actually different

  • PATH. Cron’s default PATH on most distros is /usr/bin:/bin. That’s it. Your shell’s PATH (with /usr/local/bin, /opt/homebrew/bin, ~/bin, language version managers like asdf/mise) is from your shell rc files, which cron does NOT source. So node, kubectl, aws, brew-installed binaries — all “command not found.”
  • HOME. Set, but pointing at the user’s home dir. If your script does cd ~ implicitly via referencing ~/.aws/credentials, that works. If your script expects to be in the cwd you launched it from, surprise — cron starts in the user’s HOME.
  • USER. Set correctly to the user the cron runs as. But if you cron’d it as root and the script reads ~/.aws/credentials expecting your-user’s credentials, it’ll read /root/.aws/credentials instead.
  • SHELL. Defaults to /bin/sh — which on Ubuntu is dash, not bash. Bash-isms ([[ ]], $() substitution at the top level, arrays) silently fail or behave differently.
  • No TTY. Anything that checks for an interactive terminal (tty, isatty, read -p) behaves differently. Tools like git with credential prompts, sudo with password prompts, mysql -p — all hang or fail.
  • Locale. LANG often unset; you get C locale. date formats, sort orders, character handling all subtly change. If you used printf '%.2f' in a German locale that uses commas as decimal separators, your shell does the right thing; cron’s C locale uses dots. Etc.
  • Aliases and functions. None. ll, g, k, anything in your .bashrc — all gone.

Reproduce cron’s environment in your terminal

The fastest way to debug: simulate cron’s stripped environment in your shell, then run the script and watch it break.

# Simulate cron-like environment
env -i HOME="$HOME" PATH="/usr/bin:/bin" SHELL="/bin/sh" TERM="dumb" \
    /bin/sh -c '/path/to/your/script.sh'

# If that breaks the same way the cron does, you've reproduced it.

You can also see exactly what cron passes to your job by adding this at the top of the script temporarily:

#!/usr/bin/env bash
{
  echo "=== $(date) ==="
  echo "USER=$USER  PWD=$PWD  SHELL=$SHELL"
  echo "PATH=$PATH"
  env | sort
} >> /tmp/cron-env-debug.log

# ... rest of your script

Wait one cron firing, then read /tmp/cron-env-debug.log. The diff vs your interactive shell will be obvious.

The fixes, in order of preference

# /etc/crontab or `crontab -e`
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=you@example.com

30 3 * * * /usr/local/sbin/backup-restic
*/5 * * * * /usr/local/bin/check-redis
  • Source your environment file at the top of the script if your job needs API keys / DB creds / language-version-manager setup. Don’t put secrets in crontab itself.
#!/usr/bin/env bash
set -euo pipefail
. /root/script-env.sh   # exports the variables this script needs

# rest of script
  • Use a shebang that names the shell explicitly. Don’t rely on cron’s SHELL setting. #!/usr/bin/env bash at the top guarantees bash even if SHELL is dash.
  • Capture all output. Redirect stdout AND stderr to a log file, with a timestamp. The most common form of “the cron is broken” is “the error was on stderr and went to /dev/null.”
# In crontab:
30 3 * * * /usr/local/sbin/backup-restic >> /var/log/cron-backup.log 2>&1

# Or inside the script itself, with timestamps:
exec >> /var/log/script.log 2>&1
echo "=== $(date -Iseconds) start ==="
# rest of script
echo "=== $(date -Iseconds) done ==="
  • Don’t trust MAILTO alone. By default cron emails the job’s stdout/stderr to the user’s local mail spool, which most modern boxes don’t actually deliver anywhere. Either set MAILTO=you@example.com AND make sure sendmail/msmtp can deliver, OR (better) wire to Healthchecks.io / a webhook from inside the script.
  • For language version managers (asdf, mise, rbenv, nvm, pyenv): cron will not have these initialized. Either invoke the language directly with an absolute path, or have the script source the manager’s init script:
#!/usr/bin/env bash
. /home/me/.asdf/asdf.sh   # makes `node`, `python` etc. resolve

cd /home/me/myproject
node scripts/run-this-thing.js

For systemd users — use timers instead

Most of these problems go away if you use systemd timers + service units instead of cron. Service units have explicit Environment= lines, can run as a specific user, capture output to journald (visible via journalctl -u name), and run independent of mail-delivery setup. The downside is more lines of config than a one-line crontab.

# /etc/systemd/system/backup.service
[Unit]
Description=Restic backup

[Service]
Type=oneshot
User=root
EnvironmentFile=/root/restic.env
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/usr/local/sbin/backup-restic

# /etc/systemd/system/backup.timer
[Unit]
Description=Daily restic backup

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true

[Install]
WantedBy=timers.target

# Enable: systemctl enable --now backup.timer
# Logs:   journalctl -u backup.service

The boring conclusion: cron is a fine scheduler with a famously hostile environment for the script that runs in it. Treat every line of every cron’d script defensively — absolute paths, explicit env, output captured to a real log — and the surprises stop. The week you trust your cron is the week it breaks; the year you assume it might break is the year it doesn’t.

Photo: Calendar blocks and an alarm clock by Fauzanfitria on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.