Environment variables and PATH

Variables, export, PATH lookup and startup files.

Beginner12 min · lesson 10 of 29

Every process carries a small set of named values, its environment, which it received from the process that started it. Your shell's environment decides which directories are searched for commands (PATH), where your home is, which language messages use, and it is handed down to every program you run. This lesson shows the difference between a shell variable and an environment variable, how PATH picks the program that runs when you type a name, which startup file sets what, and why the environment is a poor place for secrets. Most "it works in my terminal but not in the script" problems come down to one of these.

Shell variables and environment variables

env (or printenv with no arguments) prints the environment of the current shell:

deploy@web01 · Ubuntu 26.04 LTS
$ env | sort
DEBUGINFOD_URLS=https://debuginfod.ubuntu.com HOME=/home/deploy LANG=C.UTF-8 LOGNAME=deploy PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin PWD=/home/deploy SHELL=/bin/bash SHLVL=1 TERM=dumb USER=deploy XDG_DATA_DIRS=/usr/local/share:/usr/share:/var/lib/snapd/desktop _=/usr/bin/env

HOME, USER and SHELL come from your account, LANG sets the language and character set, PWD is the current directory and SHLVL counts how many shells deep you are. TERM names the terminal type; it is dumb here because the commands were recorded without a terminal, and in an SSH session it is something like xterm-256color. DEBUGINFOD_URLS and XDG_DATA_DIRS are set by small scripts in /etc/profile.d, and bash sets _ to the path of the command it is running, here env itself. printenv NAME prints just the values you ask for:

deploy@web01 · Ubuntu 26.04 LTS
$ printenv HOME SHELL LANG
/home/deploy /bin/bash C.UTF-8

NAME=value creates a shell variable, which lives only inside the current shell. export NAME marks it for the environment, so every program the shell starts from then on receives a copy. bash -c '...' starts a child shell, which shows the difference:

deploy@web01 · Ubuntu 26.04 LTS
$ GREETING="hello" echo "$GREETING" bash -c 'echo "child sees: [$GREETING]"' export GREETING bash -c 'echo "child sees: [$GREETING]"'
hello child sees: [] child sees: [hello]

Before export the child saw an empty value; afterwards it saw hello. A variable that is set but not exported is the most common reason a program ignores a setting. To set a variable for one command only, put the assignment in front of the command: the program gets it, and your shell does not keep it.

deploy@web01 · Ubuntu 26.04 LTS
$ LOG_LEVEL=debug printenv LOG_LEVEL echo "afterwards: [$LOG_LEVEL]"
debug afterwards: []

There must be no spaces around the =. With spaces, bash reads the first word as a command name:

deploy@web01 · Ubuntu 26.04 LTS
$ GREETING = hello
-bash: line 1: GREETING: command not found

set with no arguments lists every shell variable (and shell functions) whether exported or not, and unset NAME removes a variable. Environment changes flow one way only: a child gets a copy, and nothing it changes comes back to its parent. That is why a script that exports a variable does not change your shell, and why source FILE (run the file in the current shell) exists.

PATH: how the shell finds a command

When you type a name without a slash, bash has to find a program with that name. PATH holds the directories it searches, separated by colons, in order:

deploy@web01 · Ubuntu 26.04 LTS
$ echo "$PATH"
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
$ echo "$PATH" | tr ":" "\n"
/usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin /usr/games /usr/local/games /snap/bin
What bash does with a command name
1Alias?
interactive shells expand aliases first
2Function or builtin?
cd, echo and type run inside bash
3Remembered location
bash caches where it found a name before
4PATH, left to right
the first executable with that name runs
5Nothing found
command not found, exit status 127
A name that contains a slash, such as ./deploy.sh or /usr/bin/uptime, skips the search and runs that file.

type shows what a name will be, command -v prints the path in a form scripts can use, and which -a lists every match in PATH order:

deploy@web01 · Ubuntu 26.04 LTS
$ type cd ls command -v ls which -a ls
cd is a shell builtin ls is /usr/bin/ls /usr/bin/ls /usr/bin/ls /bin/ls

cd is a builtin and has no file at all. ls appears twice in which -a because PATH lists both /usr/bin and /bin, and /bin is a link to /usr/bin (the filesystem lesson showed why). type is the most complete answer, since it also knows about aliases and functions, which which cannot see.

Because the first match wins, every directory in PATH must be writable only by people you trust. A quick check lists each one: tr puts each directory on its own line, xargs -n1 runs ls once per directory, and ls -L follows symbolic links such as /bin, so you see the permissions of the real directory rather than of the link:

deploy@web01 · Ubuntu 26.04 LTS
$ echo "$PATH" | tr ":" "\n" | xargs -n1 ls -ldL
drwxr-xr-x 2 root root 4096 Sep 27 07:26 /usr/local/sbin drwxr-xr-x 2 root root 4096 Sep 27 07:53 /usr/local/bin drwxr-xr-x 2 root root 20480 Sep 27 07:25 /usr/sbin drwxr-xr-x 2 root root 36864 Sep 27 07:53 /usr/bin drwxr-xr-x 2 root root 20480 Sep 27 07:25 /sbin drwxr-xr-x 2 root root 36864 Sep 27 07:53 /bin drwxr-xr-x 2 root root 4096 Apr 20 08:46 /usr/games drwxr-xr-x 2 root root 4096 Sep 18 16:30 /usr/local/games ls: cannot access '/snap/bin': No such file or directory

Each directory is owned by root, and rwxr-xr-x means only the owner may add files, which is what you want. /snap/bin is listed in PATH but does not exist on this server, which is harmless: the search skips it.

Shadowing: when an earlier directory wins

Ubuntu's ~/.profile puts ~/bin and ~/.local/bin at the front of PATH when you log in, if those directories exist. Here deploy creates ~/bin and puts a script called uptime in it (chmod +x makes it executable; the permissions lesson explains the details):

deploy@web01 · Ubuntu 26.04 LTS
$ mkdir ~/bin printf "#!/bin/sh\necho \"this is not the real uptime\"\n" > ~/bin/uptime chmod +x ~/bin/uptime type -a uptime
uptime is /usr/bin/uptime uptime is /bin/uptime

Nothing changed yet: ~/.profile ran at login, when ~/bin did not exist. At the next login it does:

deploy@web01 · Ubuntu 26.04 LTS
$ echo "$PATH" type -a uptime uptime
/home/deploy/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin uptime is /home/deploy/bin/uptime uptime is /usr/bin/uptime uptime is /bin/uptime this is not the real uptime
$ /usr/bin/uptime -s
2026-09-27 03:21:14

~/bin is now first, so uptime runs the script and the real program is only reached by its full path. This is how you install personal tools, and also how a command gets hijacked: whoever can write to a directory early in your PATH decides what your commands do. The same goes for a single dot (.) or an empty entry in PATH, which both mean the current directory, so never put them there.

bash also remembers where it found each command during a session. If you add a program to an earlier directory while a shell is running, that shell may keep running the old one until you run hash -r or open a new shell.

sudo does not use your PATH. It replaces it with the secure_path from /etc/sudoers, which sudo-rs on Ubuntu reads just as the original sudo does, so a planted ~/bin/uptime never runs as root when you type sudo uptime:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo printenv PATH sudo grep secure_path /etc/sudoers
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"

The same care applies to anything that runs as root on a schedule: jobs that call bare command names are only as safe as the PATH they run with, which is why cron jobs are usually written with full paths (the scheduling lesson returns to this). The PATH that finds sudo itself is still yours, though: a program named sudo planted in ~/bin would run instead of the real one, and could ask for your password. A writable ~/bin is only as safe as your account, and type -a sudo shows which one runs. The practice script is removed again with rm ~/bin/uptime && rmdir ~/bin.

Startup files: where your environment comes from

Two properties of a shell decide which files it reads. A login shell is the first shell of a session, started by sshd or the console login; $0 starts with a dash. An interactive shell is one you type into at a prompt. The terminals in this course ran each command in a login shell that is not interactive, and a plain bash -c is neither:

deploy@web01 · Ubuntu 26.04 LTS
$ echo "$0" shopt login_shell bash -c 'shopt login_shell; echo "$0"'
-bash login_shell on login_shell off bash

For an SSH or console login on Ubuntu, the files are read in this order: /etc/environment → /etc/profile → the scripts in /etc/profile.d → ~/.profile → ~/.bashrc. The first is read by PAM, the login library that sshd and the console use, before any shell starts, and it sets PATH. bash, as a login shell, then reads /etc/profile and the first of ~/.bash_profile, ~/.bash_login and ~/.profile that exists. Ubuntu's ~/.profile adds the personal bin directories and reads ~/.bashrc.

An interactive shell that is not a login shell, such as one you start by typing bash, reads only /etc/bash.bashrc → ~/.bashrc.

deploy@web01 · Ubuntu 26.04 LTS
$ grep PATH /etc/environment
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin"
$ grep -n -A2 "private bin" ~/.profile
19:# set PATH so it includes user's private bin if it exists 20-if [ -d "$HOME/bin" ] ; then 21- PATH="$HOME/bin:$PATH" -- 24:# set PATH so it includes user's private bin if it exists 25-if [ -d "$HOME/.local/bin" ] ; then 26- PATH="$HOME/.local/bin:$PATH"

Ubuntu's ~/.bashrc starts with a guard that matters more than anything else in it. Add a variable at the end of it and look for it from a non-interactive shell:

~/.bashrc (last line)
export APP_ENV=staging
deploy@web01 · Ubuntu 26.04 LTS
$ printenv APP_ENV
$ bash -ic "printenv APP_ENV"
bash: cannot set terminal process group (-1): Inappropriate ioctl for device bash: no job control in this shell staging
$ sed -n 5,9p ~/.bashrc
# If not running interactively, don't do anything case $- in *i*) ;; *) return;; esac

$- holds the shell's option letters, and i means interactive. In any other shell ~/.bashrc returns at line 8, before your line is reached. A script, a cron job and a systemd service therefore never see what you put in ~/.bashrc. The same line in ~/.profile reaches every login, and exported variables from there are passed on to the programs the login starts:

~/.profile (last line)
export APP_ENV=staging
deploy@web01 · Ubuntu 26.04 LTS
$ printenv APP_ENV bash -c "printenv APP_ENV"
staging staging

A command run with ssh web01 'command' gets neither. Its shell is not a login shell, so ~/.profile is not read; bash on Ubuntu does notice that sshd started it and reads ~/.bashrc, but stops at the guard. Tested on the lab server, ssh to itself printed nothing for printenv APP_ENV with the line at the end of either file. Pass what the command needs on its command line (ssh web01 'APP_ENV=staging ./report.sh'), or ask for a login shell (ssh web01 'bash -lc ./report.sh'), which reads ~/.profile.

So put environment variables and PATH changes in ~/.profile, and aliases, the prompt and other interactive settings in ~/.bashrc. A PATH line in ~/.bashrc is also added again by every nested interactive shell, so the list grows. Nothing in your home directory reaches services: a systemd unit sets its own with Environment= or EnvironmentFile=, and cron gives its jobs a minimal environment of its own. If you added the practice lines, remove them again with sed -i "/^export APP_ENV=staging$/d" ~/.bashrc ~/.profile.

Where the environment can be read

The kernel publishes each process's initial environment in /proc/PID/environ, with the entries separated by null bytes, so tr '\0' '\n' makes it readable. /proc/self always means the process that is reading it:

deploy@web01 · Ubuntu 26.04 LTS
$ export DB_PASSWORD="example-not-a-secret" cat /proc/self/environ | tr "\0" "\n" | grep DB_
DB_PASSWORD=example-not-a-secret
$ export DB_PASSWORD="example-not-a-secret" tr "\0" "\n" < /proc/$$/environ | grep DB_

In the first command cat read its own environment, and it had inherited the exported variable. In the second, $$ is the shell's own PID, and the file shows the environment the shell started with, before the export; changes a process makes to its own environment later do not appear there. Other users' processes are out of reach, but their command lines are not:

deploy@web01 · Ubuntu 26.04 LTS
$ cat /proc/1/environ
cat: /proc/1/environ: Permission denied
$ tr "\0" " " < /proc/1/cmdline; echo
/usr/lib/systemd/systemd --switched-root --system --deserialize=51

deploy may not read PID 1's environment, which only its owner and root can do, yet it can read the full command line of PID 1, and of every other process, because /proc on Ubuntu 26.04 is mounted without the hidepid option that would hide it. This gives a ranking for secrets. A password on the command line is visible to every user through ps while the command runs. In the environment it is hidden from other users, but it is inherited by every child process, and it can end up in crash reports and debugging output. A file readable only by the service's account, or a systemd credential, is the better place; the hardening course's lesson on secrets shows how.

Keep secrets out of startup files
A token exported from ~/.profile or ~/.bashrc sits in a plain file in your home directory and is handed to every program you start, including ones that log their environment when they fail. Export only configuration there, and read secrets from a protected file when a program needs them.

Try this

Set a variable without exporting it, PROJECT=web, and predict what bash -c 'echo "[$PROJECT]"' prints; then export PROJECT and run it again ([], then [web]). Run type -a echo printf and decide which of the listed versions runs when you type echo (the builtin, because builtins come before the PATH search). Finally run echo "$PATH" | tr ':' '\n' | xargs -n1 ls -ldL on your own machine and check that no directory in your PATH is writable by anyone except root or you.

Takeaway

Export what child programs need and put it in ~/.profile, keep ~/.bashrc for interactive conveniences, and check with type what a name really runs. Treat every directory in PATH as code that runs with your privileges, and keep secrets out of command lines and startup files.

Quick check
01You type TOKEN=abc123, press Enter, then run ./deploy.sh, which prints an empty $TOKEN. Why?
Correct — The script runs as a child process and receives only the environment, which TOKEN was never added to.
Incorrect — The variable is still set in your shell; echo "$TOKEN" would print it. It was never passed on.
Incorrect — Children inherit every exported variable. source is for running a file inside the current shell.
Incorrect — Upper-case names are only a convention for environment variables. The shell set TOKEN normally.
02On Ubuntu you create ~/bin and put a script called backup in it. Typing backup gives "command not found", but after you log out and back in it works. Why?
Incorrect — bash searches PATH each time it meets a new name. The directory itself was not in PATH yet.
Incorrect — No approval exists. Running ~/bin/backup by its full path would have worked at once.
Correct — The check ran at your last login, before ~/bin existed; the next login added it.
Incorrect — PATH is searched left to right. The problem was that ~/bin was not in the list at all.
03You add export APP_ENV=staging at the end of ~/.bashrc on an Ubuntu server. Your terminal shows it, but ssh web01 'printenv APP_ENV' prints nothing. Why?
Incorrect — AcceptEnv controls variables sent by the client. APP_ENV was never set on the server side for this command.
Correct — bash reads ~/.bashrc for a command from sshd, but the case $- guard returns before your line.
Incorrect — A command given to ssh gets a shell that is not a login shell, so ~/.profile is not read either; moving the line there would not help.
Incorrect — A new interactive shell reads ~/.bashrc by itself. The remote command's shell is not interactive, so it stops at the guard.

Related