Shell Startup, Environment, and PATH
Why does /usr/bin/python3 work in your terminal but not in cron? Why does adding to PATH in .bashrc not work for ssh remote commands? The answer is the maze of shell startup files — different files run for different shell types, and “my env vars don’t show up” is the most common bug.
The four shell types
| Shell type | When |
|---|---|
| Login + interactive | logged in via SSH, console, bash -l, su - |
| Non-login + interactive | new terminal in a graphical session, bash (no -l) |
| Login + non-interactive | bash -l -c '...', rare |
| Non-login + non-interactive | scripts, cron, ssh cmd (without -t) |
Each runs a different combination of startup files. This is the source of all confusion.
Bash startup file order
| File | When it’s read |
|---|---|
/etc/profile |
login shells (system-wide) |
/etc/profile.d/*.sh |
sourced by /etc/profile |
~/.bash_profile |
login shells (per-user) — bash tries this first |
~/.bash_login |
login shells, if .bash_profile missing |
~/.profile |
login shells, if both above missing |
/etc/bash.bashrc |
interactive non-login shells (Debian/Ubuntu; not on RHEL) |
~/.bashrc |
interactive non-login shells |
~/.bash_logout |
login shell exit |
$BASH_ENV (env var) |
non-interactive shells (used by scripts via bash) |
For login + interactive: only ~/.bash_profile (or fallbacks) runs, NOT ~/.bashrc automatically. The standard workaround: source bashrc from bash_profile:
# ~/.bash_profile
[ -f ~/.bashrc ] && source ~/.bashrc
Now login shells get .bashrc too. Most distros ship this by default.
Login vs interactive — practical implications
| Action | Type | Files run |
|---|---|---|
ssh user@host (gets shell) |
login + interactive | .bash_profile (which usually sources .bashrc) |
ssh user@host "command" |
non-login + non-interactive | NEITHER .bash_profile NOR .bashrc |
| Open new terminal in GNOME | non-login + interactive | .bashrc only |
bash from inside a shell |
non-login + interactive | .bashrc |
bash -l |
login + interactive | .bash_profile |
Run script.sh with #!/bin/bash |
non-login + non-interactive | NEITHER (only $BASH_ENV if set) |
| Cron job | non-login + non-interactive | NEITHER |
The big deal: scripts and cron get NEITHER .bash_profile NOR .bashrc. Variables you set in those files are not visible. This is why cron jobs can’t find your venv.
Where to put environment variables
Different goals, different files:
| Goal | Put in |
|---|---|
| Available to login shells (SSH) | ~/.bash_profile or ~/.profile |
| Available to interactive shells | ~/.bashrc |
| Available to GUI apps in graphical session | ~/.profile (read by display manager) — distro-dependent |
| Available system-wide to all users | /etc/profile or /etc/environment |
| Available to systemd services | Environment= in unit file or EnvironmentFile= |
| Available to cron jobs | top of crontab, or EnvironmentFile for systemd timers |
Simple rule: put PATH/env in ~/.profile (read by login shells, including by display managers); have ~/.bash_profile source ~/.bashrc; put aliases/prompt in ~/.bashrc.
PATH
The shell looks for executables in PATH directories, left-to-right:
echo $PATH
# /home/alice/.local/bin:/usr/local/bin:/usr/bin:/bin
To add a directory:
export PATH="$HOME/bin:$PATH" # prepend (your bin shadows system)
export PATH="$PATH:$HOME/bin" # append (system shadows your bin)
Prepending is more common (“use my version first”). Appending is safer for tools that should fall back to system versions.
In ~/.profile (so login shells inherit):
if [ -d "$HOME/bin" ]; then
PATH="$HOME/bin:$PATH"
fi
export PATH
export — the variable visibility flag
NAME="alice" # shell variable, NOT visible to child processes
export NAME # mark for export, now visible to children
export NAME="alice" # both at once
Without export, the variable lives in this shell only. Subshells, scripts, and programs can’t see it.
For env vars used by other programs (PATH, EDITOR, JAVA_HOME, etc.) — always export.
export EDITOR=vim
export PYTHONDONTWRITEBYTECODE=1
export PIP_REQUIRE_VIRTUALENV=true
env — inspecting and setting environment
env # all exported vars
env | grep PATH # filter
env -i ./script.sh # run script with EMPTY environment (test "what does my script depend on")
env VAR=value cmd # run cmd with VAR set, without affecting current shell
env -i is the gold standard for “test my script with no environment leaks”:
env -i HOME="$HOME" PATH=/usr/bin:/bin sh -c './my_script.sh'
If your script breaks here, it depends on env vars you didn’t realize. Cron will hit the same issues.
Useful environment variables
| Var | Used by |
|---|---|
PATH |
shell — executable lookup |
HOME |
many programs — user’s home dir |
USER, LOGNAME |
who you are |
SHELL |
your default shell |
EDITOR, VISUAL |
git, crontab, sudoedit — which editor to open |
PAGER |
less, man — paging tool |
LANG, LC_* |
locale (sorting, date formats, encoding) |
TERM |
terminal type — affects color, ncurses |
TMPDIR |
many — where to put temp files |
LD_LIBRARY_PATH |
dynamic linker — extra .so search dirs (use with care) |
PYTHONPATH |
python — extra import dirs |
VIRTUAL_ENV |
activated venv |
PS1 |
bash — prompt format |
HISTSIZE, HISTFILE |
bash history |
Activating a virtualenv affects environment
source .venv/bin/activate
This script:
- Prepends
.venv/binto PATH (sopythonandpipresolve to the venv). - Sets
VIRTUAL_ENV=/path/to/.venv. - Modifies
PS1to show(venv)prefix.
Cron and systemd don’t run activate. Use the absolute path to the venv’s interpreter:
/opt/myapp/.venv/bin/python /opt/myapp/job.py
Aliases vs functions vs scripts
# Alias (~/.bashrc) — shell-only, no parameters in middle
alias ll='ls -lh'
alias gst='git status'
# Function (~/.bashrc) — shell-only, can take args
greet() {
echo "hello, $1"
}
# Script (somewhere on PATH) — usable from any program, including non-shell
#!/usr/bin/env bash
echo "hello, $1"
| Alias | Function | Script | |
|---|---|---|---|
| Defined in | shell startup files | shell startup files | own file |
| Visible to subshells? | no (unless re-sourced) | no (unless export -f) |
yes (it’s a file on disk) |
| Visible to non-shell programs? | no | no | yes |
| Can take dynamic args? | not in middle of command | yes | yes |
Aliases for short shortcuts. Functions for shell-internal helpers. Scripts for anything that needs to be runnable from outside an interactive shell.
Loading dotfiles for new shells
If you change ~/.bashrc, the new value isn’t in your current shell. Three options:
source ~/.bashrc # re-source in current shell (preferred)
. ~/.bashrc # equivalent, POSIX
exec bash # replace current shell with new bash
source (or .) reads the file in the current shell — env vars and aliases take effect. Running bash opens a subshell — vars in there don’t leak back to the parent.
/etc/environment vs /etc/profile
/etc/environment |
/etc/profile |
|
|---|---|---|
| Format | VAR=value lines (no export) |
shell script |
| Read by | PAM (basically every login mechanism) | login shells only |
| Conditionals / scripting | no | yes |
| Affects | most logins (SSH, GUI, su) | only bash login shells |
For “this env var should be set for everyone, regardless of shell,” use /etc/environment. For “this needs scripting logic,” use /etc/profile.d/.
Common pitfalls
- Adding to PATH in
~/.bashrcand wondering why scripts can’t see it — scripts (non-interactive) don’t read.bashrc. Put PATH in~/.profileand have.bash_profilesource.profile. - Forgetting
export— set vars are visible to child processes only afterexport. source ~/.bashrcbeing needed after edits — yes, current shell doesn’t auto-reload.- macOS uses zsh by default since Catalina — startup files are
.zshrc,.zprofile,.zshenv. Different file set, similar logic. ssh user@host cmdnot finding things in PATH — non-login non-interactive shells skip nearly all startup files. Use absolute paths.
ssh and environment
ssh user@host echo "$PATH" # often: bare /usr/bin:/bin
Two ways to get your environment:
ssh user@host "bash -l -c 'echo \$PATH'" # force login shell
ssh -t user@host # interactive (-t allocates TTY)
For automation, set what you need explicitly:
ssh user@host "PATH=/opt/bin:\$PATH /opt/myapp/script.sh"
Common interview confusions
- “
.bashrcruns every time I log in.” — only for non-login interactive shells. SSH login runs.bash_profile(which usually sources.bashrc). - “Setting a variable makes it available to scripts.” — only with
export. Without it, the variable lives in the current shell only. - “Sourcing and executing are the same.” — sourcing (
source fileor. file) runs the script in the current shell — its variables and aliases stick. Executing (./file) runs in a subshell — changes vanish. - “Aliases work in scripts.” — by default no. Aliases are interactive-only. Use functions or full commands in scripts.
Interview angle
- “Difference between
.bash_profile,.bashrc, and.profile?” —.bash_profilefor login shells (SSH, console);.bashrcfor interactive non-login shells (new terminal);.profileis fallback for.bash_profileand is read by display managers / non-bash shells. Standard practice: env vars in.profile, sourced from.bash_profile; aliases/prompt in.bashrc. - “Why doesn’t my cron job see my virtualenv?” — cron runs non-interactive non-login shells, so
~/.bashrcand~/.bash_profiledon’t run. The venv’s PATH/VIRTUAL_ENV aren’t there. Use absolute path to.venv/bin/python. - “What does
exportdo?” — marks a variable for inheritance by child processes. Without export, the variable is shell-local; with it, every child sees it. - “How do you reload
.bashrcwithout restarting the shell?” —source ~/.bashrc(or. ~/.bashrc). Reads the file in the current shell. - “
ssh user@host echo $PATHshows bare PATH — why?” — that ssh form runs a non-interactive non-login shell. Startup files don’t fire. Addbash -lor usessh -tfor an interactive session. - “What’s the difference between
/etc/environmentand/etc/profile?” —/etc/environmentisVAR=valuelines read by PAM (almost any login mechanism)./etc/profileis a shell script for login shells. For “set this for every login,” prefer/etc/environment.