# Cron Environment Variables: PATH, SHELL, and Why Scripts Fail

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cron-environment-variables

> What cron puts in a job environment on cronie, Debian cron, and BusyBox, why scripts that work in a terminal fail under cron, and how to reproduce and fix it.

**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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L350-L416)). Its `PATH` default changed in 1.7.0 ([commit](https://github.com/cronie-crond/cronie/commit/7575e75c63b57694488db33e7a5fde4c344977e1)); 1.6 and earlier use `/usr/bin:/bin`, and CentOS Stream 9 still ships cronie 1.5.7 ([package list](https://mirror.stream.centos.org/9-stream/BaseOS/x86_64/os/Packages/)). Starting crond with `-P` makes jobs inherit the daemon's `PATH` instead ([cron(8)](https://man7.org/linux/man-pages/man8/cron.8.html)). Each crontab's environment begins with the daemon's locale variables ([env.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/env.c#L123-L164)), and systemd gives services the `LANG` from [locale.conf](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html), so cronie jobs normally run in the system locale.

Debian's cron sets the same defaults from [entry.c](https://sources.debian.org/src/cron/3.0pl1-197/entry.c/#L263). Ubuntu differs on `PATH`: since 24.04, its cron service starts the daemon with `-P` ([cron.service](https://git.launchpad.net/ubuntu/+source/cron/tree/debian/cron.service?h=ubuntu/noble)), so jobs inherit the daemon's `PATH`, which systemd sets to `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin` ([systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)). A crontab `PATH=` line still overrides it. Debian's cron sets `USER` only on BSD systems, which its [crontab(5)](https://manpages.debian.org/trixie/cron/crontab.5.en.html) 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](https://github.com/mirror/busybox/blob/371fe9f71d445d18be28c82a2a6d82115c8af19d/miscutils/crond.c#L664-L690), 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/do_command.c#L297), [Debian](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L358), [BusyBox](https://github.com/mirror/busybox/blob/371fe9f71d445d18be28c82a2a6d82115c8af19d/miscutils/crond.c#L729)). That shell is neither interactive nor a login shell:

- `/bin/sh` started this way reads no startup files. That holds for [dash](https://manpages.debian.org/trixie/dash/dash.1.en.html), which is `/bin/sh` on Debian and Ubuntu, and for [bash invoked as sh](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html).
- With `SHELL=/bin/bash`, bash still skips `.bashrc`, `.bash_profile`, and `/etc/profile`. It reads only the file named in `BASH_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:

```text
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
LANG=C.UTF-8
MAILTO=ops@example.com

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-inventory
```

The rules, from [crontab(5) for cronie](https://man7.org/linux/man-pages/man5/crontab.5.html) and [for Debian](https://manpages.debian.org/trixie/cron/crontab.5.en.html):

- 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: `MAILTO=ops@example.com # team inbox` sets `MAILTO` to 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/user.c#L123-L161), [Debian](https://sources.debian.org/src/cron/3.0pl1-197/user.c/#L259)).
- Values are not expanded. `PATH=$HOME/bin:$PATH` sets `PATH` to that literal text, and a `~` stays literal too. The one exception is cronie, which expands variables in `MAILTO` and `MAILFROM`.

The command part of an entry is run by the shell, so expansion works there:

```text
# 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-cleanup
```

### Special 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](https://cronradar.com/blog/cron-no-mta-installed) covers what happens without one. `CRON_TZ` sets the zone cronie uses to match the schedule; Debian's [crontab(5)](https://manpages.debian.org/trixie/cron/crontab.5.en.html) lists per-user time zones as unsupported. [Cron time zones](https://cronradar.com/blog/cron-timezone) covers both. cronie's `RANDOM_DELAY` is an upper limit in minutes, multiplied by one random factor that crond picks at startup ([cron.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L316)), 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](https://github.com/mirror/busybox/blob/371fe9f71d445d18be28c82a2a6d82115c8af19d/miscutils/crond.c#L462-L547)). A `PATH=` line is stored, but builds with sendmail support start jobs through a function that passes no crontab `PATH` ([crond.c](https://github.com/mirror/busybox/blob/371fe9f71d445d18be28c82a2a6d82115c8af19d/miscutils/crond.c#L713)), and Alpine [builds it that way](https://gitlab.alpinelinux.org/alpine/aports/-/blob/3.22-stable/main/busybox/busyboxconfig). On Alpine, set variables in a wrapper script instead.

## Does /etc/environment Apply?

On Debian and Ubuntu, yes. The package's [PAM configuration](https://sources.debian.org/src/cron/3.0pl1-197/debian/cron.pam/) 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)](https://man7.org/linux/man-pages/man8/cron.8.html)); on Fedora and RHEL its [PAM file](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/pam/crond) includes `system-auth`, which [authselect](https://github.com/authselect/authselect/blob/master/profiles/local/system-auth) starts with `pam_env`.

Both daemons then lay their own variables over PAM's ([cronie](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/security.c#L648-L677), [Debian](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L59)). 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](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), 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](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#tag_19_08_02).

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](https://cronradar.com/blog/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](https://github.com/nvm-sh/nvm#verify-installation)). None of that runs under cron. Sourcing `~/.bashrc` from the job usually doesn't help either: Debian's default `.bashrc` [returns immediately](https://sources.debian.org/src/bash/5.2.37-2/debian/skel.bashrc/) 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:

```bash
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/ruby
```

That 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](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#bin), 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](https://github.com/pyenv/pyenv/blob/master/COMMANDS.md#pyenv-exec), [rbenv](https://github.com/rbenv/rbenv/blob/master/libexec/rbenv-exec)). 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](https://cronradar.com/blog/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](https://cronradar.com/blog/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](https://docs.python.org/3/library/venv.html#how-venvs-work). 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/security.c#L261-L271), [Debian](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L324)), 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](https://cronradar.com/blog/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](https://peps.python.org/pep-0538/). 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](https://peps.python.org/pep-0538/), [PEP 540](https://peps.python.org/pep-0540/)). 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](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap08.html) `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)](https://manpages.debian.org/trixie/cron/crontab.5.en.html) shows setting `DISPLAY`. Tools that use D-Bus also need the session bus address, which D-Bus's own [systemd user unit](https://gitlab.freedesktop.org/dbus/dbus/-/blob/main/bus/systemd-user/dbus.socket.in) puts at `bus` in the user's runtime directory, `/run/user/<uid>` ([pam_systemd](https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html)):

```text
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](https://man7.org/linux/man-pages/man1/ssh.1.html#ENVIRONMENT). 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:

```text
0 1 * * * GIT_SSH_COMMAND='ssh -i /home/deploy/.ssh/mirror_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes' git -C /srv/mirror fetch --quiet
```

[`GIT_SSH_COMMAND`](https://git-scm.com/docs/git#Documentation/git.txt-GITSSHCOMMAND) replaces the `ssh` command Git runs. [`BatchMode=yes`](https://man7.org/linux/man-pages/man5/ssh_config.5.html) 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:

```text
* * * * * 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](https://man7.org/linux/man-pages/man2/setsid.2.html), as under cron, and returns its exit status ([setsid(1)](https://man7.org/linux/man-pages/man1/setsid.1.html)). `mapfile` needs bash 4 or later, and each line of the file becomes one variable, which is fine unless a value contains a newline:

```bash
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:

```bash
#!/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.nightly
```

`set -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](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html)). 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](https://github.com/nvm-sh/nvm#installing-in-docker) 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](https://cronradar.com/blog/cron-dead-mans-switch), 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.
