Shell by Example: Environment Variables POSIX + Bash

Environment variables are key-value pairs available to every program running on your system. They store configuration like your username, home directory, and current working directory.

Access any environment variable by prefixing its name with $. The shell replaces $VARNAME with the variable’s value before running the command.

Edit
#!/bin/sh
echo "Your username: $USER"
echo "Your home directory: $HOME"
echo "Current directory: $PWD"
Output:
Your username: 
Your home directory: /root
Current directory: /tmp

There are two kinds of variables in the shell: shell variables and environment variables. Understanding the difference is essential.

A shell variable exists only in the current shell. If you start a child process, it cannot see the variable.

An environment variable is inherited by child processes. The export command promotes a shell variable into an environment variable, making it visible to any program the shell launches.

Edit
#!/bin/sh
# A regular shell variable — only visible here
SECRET="I am local"
echo "Parent sees SECRET: $SECRET"

# A child shell cannot see it
echo "Child sees SECRET:"
sh -c 'echo "  ${SECRET:-<not visible>}"'

# Export promotes it to an environment variable
export SECRET
echo ""
echo "After export:"
echo "Parent sees SECRET: $SECRET"

# Now the child shell can see it
echo "Child sees SECRET:"
sh -c 'echo "  $SECRET"'
Output:
Parent sees SECRET: I am local
Child sees SECRET:
  <not visible>

After export:
Parent sees SECRET: I am local
Child sees SECRET:
  I am local

There are several ways to create and export variables. Each approach has slightly different behavior.

  • VAR=value creates a shell-only variable
  • export VAR=value creates and exports in one step
  • export VAR exports an existing shell variable

The key test: can a child process see the variable? Only exported variables are inherited by children.

Edit
#!/bin/sh
# Method 1: Shell-only variable
DB_NAME="mydb"
echo "Method 1 - shell variable:"
echo "  Parent: DB_NAME=$DB_NAME"
sh -c 'echo "  Child:  DB_NAME=${DB_NAME:-<not visible>}"'

# Method 2: Set and export in one step
echo ""
echo "Method 2 - export in one step:"
export DB_HOST="localhost"
echo "  Parent: DB_HOST=$DB_HOST"
sh -c 'echo "  Child:  DB_HOST=$DB_HOST"'

# Method 3: Set first, export later
echo ""
echo "Method 3 - export on a separate line:"
DB_PORT="5432"
export DB_PORT
echo "  Parent: DB_PORT=$DB_PORT"
sh -c 'echo "  Child:  DB_PORT=$DB_PORT"'
Output:
Method 1 - shell variable:
  Parent: DB_NAME=mydb
  Child:  DB_NAME=<not visible>

Method 2 - export in one step:
  Parent: DB_HOST=localhost
  Child:  DB_HOST=localhost

Method 3 - export on a separate line:
  Parent: DB_PORT=5432
  Child:  DB_PORT=5432

The system and shell set several standard environment variables. Knowing these helps you write portable scripts that adapt to the user’s environment.

  • USER: The current username (set at login)
  • HOME: The user’s home directory (set at login)
  • PWD: The current working directory (updated by cd)
  • PATH: Colon-separated list of directories searched for commands (set by shell config files)
  • SHELL: The user’s default login shell (set at login)
  • TERM: The terminal type, used by programs that draw to the screen (set by the terminal emulator)
  • LANG: The system locale, controls language and formatting for dates, numbers, etc. (set by OS)
Edit
#!/bin/sh
echo "USER:  $USER"
echo "HOME:  $HOME"
echo "PWD:   $PWD"
echo "PATH:  $PATH"
echo "SHELL: $SHELL"
echo "TERM:  $TERM"
echo "LANG:  $LANG"
Output:
USER:  
HOME:  /root
PWD:   /tmp
PATH:  /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL: 
TERM:  
LANG:  

Scripts often need to check whether a variable is set and provide fallback values. The shell offers two approaches: test flags and parameter expansion.

Test flags:

  • [ -n "$VAR" ] is true if VAR is non-empty
  • [ -z "$VAR" ] is true if VAR is empty or unset

Parameter expansion provides four forms:

  • ${VAR:-default} Use default if VAR is unset/empty
  • ${VAR:=default} Use default AND assign it to VAR
  • ${VAR:?message} Exit with error if VAR is unset/empty
  • ${VAR:+alternate} Use alternate only if VAR IS set
Edit
#!/bin/sh
# Test flags for conditional logic
echo "Checking with test flags:"
if [ -n "$HOME" ]; then
    echo "  HOME is set to: $HOME"
fi
if [ -z "$UNDEFINED_VAR" ]; then
    echo "  UNDEFINED_VAR is not set"
fi

# ${VAR:-default} — provide a fallback without changing VAR
echo ""
echo "Fallback values with \${VAR:-default}:"
echo "  DB_HOST: ${DB_HOST:-localhost}"
echo "  DB_PORT: ${DB_PORT:-5432}"

# ${VAR:=default} — provide a fallback AND assign it
echo ""
echo "Assign defaults with \${VAR:=default}:"
: "${CACHE_DIR:=/tmp/cache}"
echo "  CACHE_DIR is now: $CACHE_DIR"

# ${VAR:+alternate} — use a value only if VAR is set
echo ""
echo "Alternate values with \${VAR:+alternate}:"
echo "  USER is set: ${USER:+yes}"
echo "  MISSING is set: ${MISSING_VAR:+yes}"

# ${VAR:?message} — require a variable or exit
# Uncommenting the line below would exit the script
# if REQUIRED_CONFIG is not set:
#   : "${REQUIRED_CONFIG:?must be set in environment}"
echo ""
echo "\${VAR:?message} exits if VAR is unset (commented out above)"
Output:
Checking with test flags:
  HOME is set to: /root
  UNDEFINED_VAR is not set

Fallback values with ${VAR:-default}:
  DB_HOST: localhost
  DB_PORT: 5432

Assign defaults with ${VAR:=default}:
  CACHE_DIR is now: /tmp/cache

Alternate values with ${VAR:+alternate}:
  USER is set: 
  MISSING is set: 

${VAR:?message} exits if VAR is unset (commented out above)

Sometimes you need a variable only for a single command or the current session, without permanently changing the environment.

Three approaches:

  1. VAR=value command — sets VAR only for that command
  2. env VAR=value command — same, using the env utility
  3. Modifying PATH in the current shell session
Edit
#!/bin/sh
# Inline assignment: VAR exists only for the command
echo "Inline assignment (VAR=value command):"
GREETING="Hola" sh -c 'echo "  During: GREETING=$GREETING"'
echo "  After:  GREETING=${GREETING:-<gone>}"

# The env utility does the same thing
echo ""
echo "Using env:"
env COLOR="blue" sh -c 'echo "  During: COLOR=$COLOR"'
echo "  After:  COLOR=${COLOR:-<gone>}"

# Modifying PATH for the current session
echo ""
echo "Modifying PATH temporarily:"
echo "  PATH has $(echo "$PATH" | tr ':' '\n' | wc -l | tr -d ' ') entries"
PATH="$HOME/bin:$PATH"
echo "  After prepend, PATH starts with: ${PATH%%:*}"
Output:
Inline assignment (VAR=value command):
  During: GREETING=Hola
  After:  GREETING=<gone>

Using env:
  During: COLOR=blue
  After:  COLOR=<gone>

Modifying PATH temporarily:
  PATH has 6 entries
  After prepend, PATH starts with: /root/bin

To remove a variable from the environment, use unset. To see what’s currently exported, use env. To run a command with a completely clean environment, use env -i.

These tools are useful for testing, debugging, and isolating programs from the surrounding environment.

Edit
#!/bin/sh
# unset removes a variable entirely
export TEMP_VAR="temporary"
echo "Before unset: TEMP_VAR=$TEMP_VAR"
unset TEMP_VAR
echo "After unset:  TEMP_VAR=${TEMP_VAR:-<removed>}"

# env with no arguments lists all exported variables
echo ""
echo "Exported variables (first 5):"
env | sort | head -5

# env -i runs a command with an empty environment
# Here we pass only PATH so the child can find commands
echo ""
echo "Clean environment with env -i:"
env -i PATH="$PATH" sh -c '
    count=$(env | wc -l | tr -d " ")
    echo "  Variables in clean env: $count"
    echo "  PATH is set: ${PATH:+yes}"
    echo "  USER is set: ${USER:-<not set>}"
'
Output:
Before unset: TEMP_VAR=temporary
After unset:  TEMP_VAR=<removed>

Exported variables (first 5):
FAKETIME=2025-01-01 10:00:00
FAKETIME_NO_CACHE=1
FAKETIME_SHARED=/faketime_sem_1 /faketime_shm_1
HOME=/root
HOSTNAME=shellbyexample

Clean environment with env -i:
  Variables in clean env: 3
  PATH is set: yes
  USER is set: <not set>

A common pattern in real applications is loading configuration from a .env file. The shell’s source command (or .) reads a file and executes it in the current shell, which sets the variables it contains.

By default, sourced variables are shell-only. To make them environment variables (visible to child processes), use set -a before sourcing. This tells the shell to automatically export every variable that gets assigned.

Edit
#!/bin/sh
# Create a sample config file
cat > /tmp/app.env <<'EOF'
APP_HOST=0.0.0.0
APP_PORT=3000
APP_DEBUG=false
EOF

# Source without set -a: variables are shell-only
. /tmp/app.env
echo "Without set -a:"
echo "  Parent sees APP_HOST: $APP_HOST"
sh -c 'echo "  Child sees APP_HOST:  ${APP_HOST:-<not visible>}"'

# Source with set -a: variables are auto-exported
set -a
. /tmp/app.env
set +a

echo ""
echo "With set -a:"
echo "  Parent sees APP_HOST: $APP_HOST"
sh -c 'echo "  Child sees APP_HOST:  $APP_HOST"'

# A config function with overridable defaults
echo ""
echo "Config function with defaults:"
show_config() {
    echo "  APP_PORT=${APP_PORT:-3000}"
    echo "  APP_HOST=${APP_HOST:-0.0.0.0}"
    echo "  APP_DEBUG=${APP_DEBUG:-false}"
}

show_config
echo ""
echo "Overridden via inline variables:"
APP_PORT=8080 APP_DEBUG=true show_config

rm /tmp/app.env
Output:
Without set -a:
  Parent sees APP_HOST: 0.0.0.0
  Child sees APP_HOST:  <not visible>

With set -a:
  Parent sees APP_HOST: 0.0.0.0
  Child sees APP_HOST:  0.0.0.0

Config function with defaults:
  APP_PORT=3000
  APP_HOST=0.0.0.0
  APP_DEBUG=false

Overridden via inline variables:
  APP_PORT=8080
  APP_HOST=0.0.0.0
  APP_DEBUG=true

Bash

Bash provides built-in commands for inspecting and controlling variable attributes that aren’t available in POSIX sh.

  • declare -rx VAR=value creates a read-only export
  • export -p lists all exported variables
  • declare -p VAR shows a variable’s attributes

These are useful for debugging and for protecting critical variables from accidental modification.

Edit
#!/bin/bash
# declare -rx creates a variable that is both exported
# and read-only — it cannot be changed or unset
declare -rx APP_VERSION="1.0.0"
echo "Read-only export:"
echo "  APP_VERSION=$APP_VERSION"

# Attempting to change it would produce an error:
#   APP_VERSION="2.0.0"  # bash: APP_VERSION: readonly variable

# export -p lists all exported variables with their values
echo ""
echo "All exported variables (first 5):"
export -p | head -5

# declare -p shows a specific variable's attributes
# The flags tell you how the variable was declared:
#   -x means exported, -r means read-only
echo ""
echo "Inspecting APP_VERSION:"
declare -p APP_VERSION

echo ""
echo "Inspecting HOME:"
declare -p HOME
Output:
Read-only export:
  APP_VERSION=1.0.0

All exported variables (first 5):
declare -rx APP_VERSION="1.0.0"
declare -x FAKETIME="2025-01-01 10:00:00"
declare -x FAKETIME_NO_CACHE="1"
declare -x FAKETIME_SHARED="/faketime_sem_1 /faketime_shm_1"
declare -x HOME="/root"

Inspecting APP_VERSION:
declare -rx APP_VERSION="1.0.0"

Inspecting HOME:
declare -x HOME="/root"

Environment variables are convenient for configuration, but they are a poor choice for storing secrets like API keys, passwords, and tokens.

Why? Because environment variables are:

  • Inherited by every child process
  • Visible in /proc/PID/environ on Linux
  • Often captured in error logs and debug output
  • Exposed by commands like env and printenv

A safer alternative is storing secrets in files with restricted permissions, and reading them when needed.

Edit
#!/bin/sh
# Demonstrating the risk: child processes see everything
export API_KEY="secret123"
echo "Child process can see the secret:"
sh -c 'echo "  API_KEY=$API_KEY"'
unset API_KEY

# Better approach: use a file with restricted permissions
echo ""
echo "Storing a secret in a file instead:"
echo "my_secret_token" >/tmp/secret.txt
chmod 600 /tmp/secret.txt

# Only the file owner can read it
echo "  Permissions: $(ls -l /tmp/secret.txt | cut -d' ' -f1)"

# Read the secret only when needed
SECRET=$(cat /tmp/secret.txt)
echo "  Secret read from file: ${SECRET}"

rm /tmp/secret.txt
echo "  Cleaned up secret file"
Output:
Child process can see the secret:
  API_KEY=secret123

Storing a secret in a file instead:
  Permissions: -rw-------
  Secret read from file: my_secret_token
  Cleaned up secret file

« Getopts | Reading Files »