backend / linux bash / 15_shell_startup_environment.md

Shell Startup, Environment, and PATH

6 interview angles 8 min read source

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/bin to PATH (so python and pip resolve to the venv).
  • Sets VIRTUAL_ENV=/path/to/.venv.
  • Modifies PS1 to 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 ~/.bashrc and wondering why scripts can’t see it — scripts (non-interactive) don’t read .bashrc. Put PATH in ~/.profile and have .bash_profile source .profile.
  • Forgetting export — set vars are visible to child processes only after export.
  • source ~/.bashrc being 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 cmd not 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

  • .bashrc runs 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 file or . 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_profile for login shells (SSH, console); .bashrc for interactive non-login shells (new terminal); .profile is fallback for .bash_profile and 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 ~/.bashrc and ~/.bash_profile don’t run. The venv’s PATH/VIRTUAL_ENV aren’t there. Use absolute path to .venv/bin/python.
  • “What does export do?” — 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 .bashrc without restarting the shell?”source ~/.bashrc (or . ~/.bashrc). Reads the file in the current shell.
  • ssh user@host echo $PATH shows bare PATH — why?” — that ssh form runs a non-interactive non-login shell. Startup files don’t fire. Add bash -l or use ssh -t for an interactive session.
  • “What’s the difference between /etc/environment and /etc/profile?”/etc/environment is VAR=value lines read by PAM (almost any login mechanism). /etc/profile is a shell script for login shells. For “set this for every login,” prefer /etc/environment.