Cron Environment Variables: PATH, SHELL, and Why Scripts Fail
Contents
Cron starts every job with a small environment it builds itself: SHELL=/bin/sh, HOME and LOGNAME from the crontab owner's account, and a short PATH such as /usr/bin:/bin. It reads none of your shell startup files, so nothing from ~/.bashrc or ~/.profile reaches the job. That is why a script that works in your terminal fails under cron with command not found, the wrong interpreter, or a file it can't find. The details differ by implementation. This guide gives each one's behavior from its source: cronie 1.7.2 (Fedora, RHEL and derivatives, Arch), Debian's cron 3.0pl1-197 (Debian 13; Ubuntu ships the same package at its own version), and BusyBox crond 1.37.0 (Alpine and minimal images), checked October 24, 2026.
What Cron Puts in a Job's Environment#
| Variable | cronie 1.7 | Debian cron | BusyBox crond 1.37 |
|---|---|---|---|
SHELL |
/bin/sh; the crontab can override it |
/bin/sh; the crontab can override |
Crontab SHELL=, else crond's own $SHELL, else its user's login shell |
HOME |
Owner's home; the crontab can override it | Owner's home; the crontab can override | Owner's home |
LOGNAME |
Owner; can't be overridden | Owner; can't be overridden | Owner |
USER |
Owner; can't be overridden | Not set | Owner |
PATH |
/usr/bin:/bin:/usr/sbin:/sbin; overridable |
/usr/bin:/bin (Ubuntu 24.04+: crond's own); overridable |
Whatever crond was started with |
Locale (LANG, LC_*) |
Copied from crond's own environment | From PAM, including /etc/default/locale |
Whatever crond was started with |
| Rest of crond's environment | Only MAILFROM and RANDOM_DELAY |
Not passed | Passed |
| Working directory | $HOME |
$HOME |
Owner's home, else /var/spool/cron |
cronie applies the crontab's own variables first, then fills in SHELL, HOME, and PATH only where the crontab left them unset, and always overwrites LOGNAME and USER (entry.c). Its PATH default changed in 1.7.0 (commit); 1.6 and earlier use /usr/bin:/bin, and CentOS Stream 9 still ships cronie 1.5.7 (package list). Starting crond with -P makes jobs inherit the daemon's PATH instead (cron(8)). Each crontab's environment begins with the daemon's locale variables (env.c), and systemd gives services the LANG from locale.conf, so cronie jobs normally run in the system locale.
Debian's cron sets the same defaults from entry.c. Ubuntu differs on PATH: since 24.04, its cron service starts the daemon with -P (cron.service), so jobs inherit the daemon's PATH, which systemd sets to /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin (systemd.exec). A crontab PATH= line still overrides it. Debian's cron sets USER only on BSD systems, which its crontab(5) also says. A script that reads $USER gets an empty string on Debian and Ubuntu; add export USER="${USER:-$LOGNAME}" near its top. The trixie manual writes the shell as /usr/bin/sh, the same file as /bin/sh on a merged-/usr system.
BusyBox crond sets LOGNAME, USER, HOME, and SHELL on top of its own environment and starts the job with everything else it has (crond.c, identical to the 1.37.0 release). In a container where crond is the main process, variables given to the container reach BusyBox jobs. Under cronie and Debian cron they reach the crond process but not its jobs, apart from cronie's copied locale variables.
What Cron Doesn't Load#
All three run the entry as $SHELL -c 'command' (cronie, Debian, BusyBox). That shell is neither interactive nor a login shell:
/bin/shstarted this way reads no startup files. That holds for dash, which is/bin/shon Debian and Ubuntu, and for bash invoked as sh.- With
SHELL=/bin/bash, bash still skips.bashrc,.bash_profile, and/etc/profile. It reads only the file named inBASH_ENV, if that variable is set.
Aliases, shell functions, export lines, and everything nvm, pyenv, or rbenv added to your startup files are absent from the job.
Setting Variables in a Crontab#
cronie and Debian cron treat any name = value line as an environment setting for the entries below it:
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
LANG=C.UTF-8
[email protected]
15 3 * * * /srv/app/bin/nightly-report >> /var/log/app/nightly-report.log 2>&1
# Entries from here down get this PATH instead
PATH=/opt/inventory/bin:/usr/bin:/bin
*/10 * * * * sync-inventoryThe rules, from crontab(5) for cronie and for Debian:
- Spaces around
=are optional. Matching single or double quotes keep leading or trailing blanks. - A comment on the same line becomes part of the value:
[email protected] # team inboxsetsMAILTOto all of that text. - A setting applies to every entry after it until the next setting of the same name. Each entry copies the environment as it stands when the parser reaches it (cronie, Debian).
- Values are not expanded.
PATH=$HOME/bin:$PATHsetsPATHto that literal text, and a~stays literal too. The one exception is cronie, which expands variables inMAILTOandMAILFROM.
The command part of an entry is run by the shell, so expansion works there:
# Does not work: cron stores this text as written
PATH=$HOME/bin:$PATH
# Works: the shell expands it when the job runs
0 * * * * export PATH="$HOME/bin:$PATH"; hourly-cleanupSpecial Variables by Implementation#
| Variable | cronie 1.7 | Debian cron | BusyBox crond 1.37 |
|---|---|---|---|
MAILTO |
Yes | Yes | Yes, in builds with sendmail support, such as Alpine's |
MAILFROM |
Yes | Yes | No |
CRON_TZ |
Yes | No; TZ affects only the command |
No |
RANDOM_DELAY |
Yes | No | No |
SHELL |
Yes | Yes | Yes |
PATH |
Yes | Yes | Parsed, but applied only in builds without sendmail support |
Any other NAME=value |
Yes | Yes | Silently ignored |
MAILTO names who receives a job's output, and MAILTO="" turns mail off. Delivery needs a mail transfer agent; cron mail with no MTA installed covers what happens without one. CRON_TZ sets the zone cronie uses to match the schedule; Debian's crontab(5) lists per-user time zones as unsupported. Cron time zones covers both. cronie's RANDOM_DELAY is an upper limit in minutes, multiplied by one random factor that crond picks at startup (cron.c), so an entry's delay stays the same until crond restarts.
BusyBox is the outlier. Its parser recognizes only lines beginning MAILTO=, SHELL=, or PATH=, written with no spaces around =, and keeps any quotes as part of the value. It skips any other line with fewer than six fields without an error (crond.c). A PATH= line is stored, but builds with sendmail support start jobs through a function that passes no crontab PATH (crond.c), and Alpine builds it that way. On Alpine, set variables in a wrapper script instead.
Does /etc/environment Apply?#
On Debian and Ubuntu, yes. The package's PAM configuration runs pam_env, which reads /etc/environment and /etc/security/pam_env.conf, and runs it again for /etc/default/locale. cronie loads the PAM environment through pam_env too (cron(8)); on Fedora and RHEL its PAM file includes system-auth, which authselect starts with pam_env.
Both daemons then lay their own variables over PAM's (cronie, Debian). Because cron always sets PATH, a PATH line in /etc/environment never reaches a job, while a variable cron doesn't set, such as HTTPS_PROXY, does. BusyBox crond doesn't use PAM; its jobs see only what crond itself inherited from OpenRC or the container's entrypoint.
Common Breakages and Their Fixes#
Command Not Found#
The cronie and Debian defaults leave out /usr/local/bin and ~/.local/bin, where many installers put tools. The AWS CLI's installers, for example, link aws into one of those two (install guide, checked October 24, 2026). Under dash the failure reads /bin/sh: 1: aws: not found, and the job exits 127, the status POSIX reserves for a command that isn't found.
Find the full path in your terminal with command -v aws, then use it in the entry, or set PATH at the top of the crontab or in a wrapper. On Debian, /etc/crontab sets a longer PATH for system entries than user crontabs get, so a command copied from there into crontab -e can stop resolving. The crontab guide covers which file holds which entries.
nvm, pyenv, and rbenv#
These tools work by changing PATH from your shell startup files, and nvm is a shell function rather than a program (nvm README). None of that runs under cron. Sourcing ~/.bashrc from the job usually doesn't help either: Debian's default .bashrc returns immediately when the shell isn't interactive, before the lines installers append.
Ask each tool for the absolute path once and use it in the job:
nvm which 22 # /home/deploy/.nvm/versions/node/v22.11.0/bin/node
pyenv which python # /home/deploy/.pyenv/versions/3.12.4/bin/python
rbenv which ruby # /home/deploy/.rbenv/versions/3.3.5/bin/rubyThat path changes when you upgrade the runtime, so keep it in one wrapper rather than in several crontab lines. A program whose first line is #!/usr/bin/env node, as npm asks of every package's bin scripts, also needs that bin directory on PATH. pyenv exec and rbenv exec do that for you: they prepend the selected version's bin directory to PATH (pyenv, rbenv). Call them by full path too, such as /home/deploy/.pyenv/bin/pyenv exec. They pick the version from .python-version or .ruby-version in the current directory, so cd into the project first. Rails scheduled jobs covers the same problem for the whenever gem, whose jobs run in a login shell.
Virtual Environments#
Calling a virtualenv's interpreter by full path replaces activation, as reviewing AI-generated cron jobs shows. Console scripts in the environment's bin directory, such as celery or alembic, also run without it, because their first line points to that interpreter. The catch is a program that starts another one by name, such as subprocess.run(["alembic", "upgrade", "head"]). That lookup uses PATH, so put /srv/app/.venv/bin on it.
The Working Directory#
cronie and Debian cron start the job in $HOME (cronie, Debian), using the crontab's HOME if it sets one, though that also changes where tools look for files under ~. BusyBox uses the home directory from /etc/passwd. A script that opens config.yml or writes ./output works only from the directory you tested in. cd to the project at the top of the wrapper and stop if it fails, so relative paths never resolve against the wrong directory. Cron job not running covers the user, permission, and logging checks around this.
Locale and Encoding#
When no LANG or LC_* variable reaches the job, programs run in the C locale, which typically implies ASCII. Python 3.6 and earlier could then fail with UnicodeEncodeError on non-ASCII text; 3.7 and later switch to UTF-8 in the C locale (PEP 538, PEP 540). Other effects are quieter: sort, ls, and date output can differ from what you saw in your terminal. Set LANG explicitly to a locale that locale -a lists on the machine. LC_ALL overrides LANG and every other LC_* variable, so set it only when you mean that.
Desktop Tools#
notify-send, xdotool, and gsettings need the user's graphical session, which cron knows nothing about. Debian's crontab(5) shows setting DISPLAY. Tools that use D-Bus also need the session bus address, which D-Bus's own systemd user unit puts at bus in the user's runtime directory, /run/user/<uid> (pam_systemd):
DISPLAY=:0
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
0 17 * * 1-5 notify-send "Timesheet" "Log today's hours"Use your own id -u in place of 1000. This works only while that user is logged in to a desktop session.
ssh-agent and Git Over SSH#
Your terminal session reaches ssh-agent through SSH_AUTH_SOCK, the agent's socket. Cron doesn't set it, so a job can't use agent-held or passphrase-protected keys, and git fetch or rsync over SSH fails with Permission denied (publickey). Give the job its own key, readable only by the job's account, and name it explicitly:
0 1 * * * GIT_SSH_COMMAND='ssh -i /home/deploy/.ssh/mirror_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes' git -C /srv/mirror fetch --quietGIT_SSH_COMMAND replaces the ssh command Git runs. BatchMode=yes disables password prompts and host key confirmation, so a missing key or an unknown host fails immediately. Add the host to that account's known_hosts before the first run.
Reproduce Cron's Environment#
Capture It#
Add a temporary entry, wait for the next minute, then remove it:
* * * * * umask 077; env > "$HOME/cron-env.txt"The file shows exactly what your implementation passed, including anything from PAM. It can contain secrets set in the crontab, so umask 077 keeps it private; delete it when you're done.
Run the Job With It#
As the account that owns the crontab, start from an empty environment with env -i, add only the captured variables, and start in the same directory. setsid -w runs the command in a new session with no controlling terminal, as under cron, and returns its exit status (setsid(1)). mapfile needs bash 4 or later, and each line of the file becomes one variable, which is fine unless a value contains a newline:
cd "$HOME"
mapfile -t cron_env < "$HOME/cron-env.txt"
# Check what resolves before running anything with side effects
setsid -w env -i "${cron_env[@]}" /bin/sh -c '
for c in node python3 aws; do command -v "$c" || echo "$c: not found"; done
pwd; locale' < /dev/null
# Then run the job the way cron would
setsid -w env -i "${cron_env[@]}" /bin/sh -c '/srv/app/bin/nightly-report' < /dev/null > /tmp/nightly-report-test.log 2>&1
echo "exit status: $?"Running the job for real repeats its side effects, so use a dry-run mode if it has one.
Make the Job Set Its Own Environment#
The durable fix is a wrapper that doesn't depend on what cron passes, so it behaves the same under cronie, Debian cron, BusyBox, and your terminal:
#!/bin/sh
# /srv/app/bin/nightly-report
set -eu
PATH=/srv/app/.venv/bin:/usr/local/bin:/usr/bin:/bin
LANG=C.UTF-8
export PATH LANG
# Settings and secrets, in shell syntax: mode 600, owned by the job's account
set -a
. /etc/app/nightly-report.env
set +a
cd /srv/app
exec python -m app.reports.nightlyset -e stops the script if the settings file or the cd fails, and exec hands the job's exit status straight back to cron. The crontab line becomes 15 3 * * * /srv/app/bin/nightly-report >> /var/log/app/nightly-report.log 2>&1.
When Sourcing a Profile Is Acceptable#
Sourcing a profile is reasonable for a dedicated service account whose ~/.profile only sets variables, because then it is the single definition of the job's environment. Start the command with bash -lc, which reads /etc/profile and then the first of ~/.bash_profile, ~/.bash_login, and ~/.profile that exists (Bash manual). Avoid it for a personal account: interactive profiles can print output that cron mails, start agents, change terminal settings, or return early. A middle ground is one plain file of export lines that both your profile and the job source. In the crontab, SHELL=/bin/bash plus BASH_ENV pointing at that file loads it before every entry, the approach nvm's README suggests for non-interactive shells. That only works where the crontab can set variables, so not on Alpine.
Catch the Failure the First Night#
An environment bug is loud only if something is listening: a 127 exit goes to cron's mail, which may never be delivered. A wrapper that reports its result, like the one in the dead man's switch guide, turns that exit code into a fail check-in, which CronRadar alerts on immediately, and a job that never reports at all goes critical once its grace period passes. There is no CronRadar SDK or auto-discovery for crontab, so each job reports with its own HTTP check-in. Put the reporting calls in the same wrapper, after it loads its settings file, and keep CRONRADAR_API_KEY in that file: BusyBox ignores a CRONRADAR_API_KEY= line in the crontab.
Frequently Asked Questions#
What PATH does cron use?#
On Debian, user crontab entries get /usr/bin:/bin. Ubuntu 24.04 and later start cron with -P, so jobs inherit the daemon's PATH, /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin under systemd. cronie 1.7 and later use /usr/bin:/bin:/usr/sbin:/sbin, and older cronie releases use /usr/bin:/bin. BusyBox crond passes along whatever PATH crond started with. Neither the Debian nor the cronie default includes /usr/local/bin.
How do I set an environment variable for a cron job?#
With cronie or Debian cron, put NAME=value on its own line above the entry; it applies to every entry below it, and the value isn't expanded. For a value that needs expansion, set it in the command, as in export PATH="$HOME/bin:$PATH"; job. With BusyBox crond, or when several variables are involved, set them in a wrapper script.
Does cron load .bashrc or .profile?#
No. Cron runs each entry with $SHELL -c, a non-interactive, non-login shell, which skips both files and /etc/profile. Debian's default .bashrc also exits early in a non-interactive shell, so sourcing it from the job doesn't load what installers appended. Set the variables in the crontab or in a wrapper, or start the command with bash -lc if your profile is safe to run unattended.
Why does my script say command not found in cron?#
The program lives in a directory that isn't on cron's PATH, or it was set up by a version manager that runs only from your shell startup files. Run command -v program in your terminal and use the full path it prints, or add that directory to PATH in the crontab or wrapper. The job exits with status 127.
How do I test a script with cron's environment?#
Capture the real environment with a temporary env > "$HOME/cron-env.txt" entry, then run the script with env -i and those variables, from your home directory, under setsid -w. Check that commands resolve before running anything with side effects.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.