Skip to content
shellhacks.dev
Shell Scripting

How to Use Environment Variables to Configure a Shell Script

Configure a shell script through environment variables, with prefixes, defaults, required-variable checks and the export rules that decide what children see.

6 min read
Summarize in AI
Illustration for "How to Use Environment Variables to Configure a Shell Script": a shell script in an editor, a loop arrow, environment variables.

Environment variables are the easiest way to configure a script that runs from cron, a container or a CI job, since none of those has a convenient place for flags. The rules for defaults and exports are small but easy to get wrong. This post uses Bash 5.2 and complements configuration files and defaults.

Read with defaults and required checks

Shell
#!/usr/bin/env bash
set -euo pipefail

PORT=${APP_PORT:-8080}
HOST=${APP_HOST:-127.0.0.1}
TOKEN=${APP_TOKEN:?APP_TOKEN must be set}

echo "listening on $HOST:$PORT"

${APP_PORT:-8080} uses the default when the variable is unset or empty. ${APP_TOKEN:?message} aborts with the message when the variable is unset or empty, which is clearer than a failure three steps later. Without the colon, an empty value counts as set, a difference covered in default values for variables.

Prefix your names

Names such as PORT, HOST and USER collide with variables set by the shell, the OS or other tools. A prefix like APP_ avoids that, and makes it easy to list the script's configuration with env | grep '^APP_'. Uppercase is the convention for environment names and lowercase for script-local variables, which keeps the two apart.

Export only what children need

Shell
APP_MODE=fast ./worker.sh      # for this command only
export LC_ALL=C                # for every later child

A plain assignment such as PORT=8080 creates a shell variable that child processes do not see. export places it in the environment passed to children. Prefixing a single command, as in the first line, exports for that one command and leaves the shell unchanged. The distinction is explained in exporting environment variables.

Shell
if [[ ${APP_DEBUG:-0} == 1 ]]; then
  printf 'port=%s host=%s\n' "$PORT" "$HOST" >&2
fi

Print the resolved values, never the raw environment, and never the token. env and set output in a log file is a common way for a secret to end up in an artifact store.

How the environment is passed down

A process has a block of text entries, name and value pairs, that it receives at start and passes to its children. The shell turns the exported variables into that block each time it starts a command. A change in a child never travels back to the parent, which is why a script that runs export PATH=... does not alter the terminal that launched it. Running the script with source or a dot executes it inside the current shell and so does change the environment, and that is the usual way to load a file of settings.

Where the variables come from depends on how the script starts. An interactive shell reads profile files. Cron provides a very small set: typically HOME, LOGNAME, SHELL and a short PATH, so a job that works in a terminal can fail with "command not found" under cron. Systemd units get an environment from Environment= and EnvironmentFile= lines. A container receives what -e or --env-file passes, plus the image defaults. CI systems inject their own variables and mask secrets in logs, with the caveat that masking matches exact strings and an encoded secret escapes it.

Using an env file safely

A file of KEY=value lines is the common way to hold configuration. Loading it with source runs it as shell code, so a value containing a command substitution or a semicolon executes. For a file you control, that is acceptable. For anything else, parse it line by line, and accept only names that match a pattern.

Shell
while IFS='=' read -r key value; do
  [[ $key =~ ^APP_[A-Z0-9_]+$ ]] || continue
  export "$key=$value"
done < /etc/myapp/env

The loop ignores comments and blank lines because they fail the name test, and it exports only names with the expected prefix, so a stray PATH= or LD_PRELOAD= in the file cannot change how later commands run. It does not strip quotes from values, which matches how Docker reads an env file but differs from how a shell would, a mismatch worth knowing about when the same file is used by both.

Types, lists and booleans

Everything in the environment is a string. A flag such as APP_DEBUG=false is a non-empty string, so a test like [[ -n $APP_DEBUG ]] treats it as true. Decide on one convention, for example 1 for on and anything else for off, document it, and test with an explicit comparison. Lists are usually colon-separated, as in PATH, or comma-separated, and reading one into an array is IFS=, read -ra hosts <<< "$APP_HOSTS". Values with spaces survive if quoted when set, and fail in the places that split on whitespace.

Inspecting and debugging

env prints the environment a child would receive, printenv NAME prints one value, and declare -p NAME shows whether a variable is exported and what it holds in the current shell. To see what a running process has, read /proc/PID/environ, which separates entries with NUL bytes, so pipe it through tr '\0' '\n'. To start a clean test, use env -i PATH=/usr/bin:/bin ./script.sh, which removes everything but the variables you name and reproduces the bare conditions of cron. Running the script that way once before deploying it finds nearly every hidden dependency on the login environment.

Precedence between sources

A script that reads settings from flags, an env file and the environment needs a stated order. The common rule is that a flag beats the environment, the environment beats a config file, and the config file beats the built-in default. Implement it by setting defaults first, overlaying the file, then the environment, then parsing flags last. Document the order in the usage text, because the first question after a surprise is which source won. Printing the resolved values and their origin at debug level, such as port=9000 (from APP_PORT), answers it in one line.

Be careful with names that tools read on their own. HTTP_PROXY, TZ, LANG, LC_ALL and EDITOR change behaviour of programs the script starts, so a value set for one purpose leaks into others. Setting LC_ALL=C for a single sort call, written as a prefix, gives byte order for that command without changing the rest of the run.

Testing configuration code

Because the behaviour depends on what is set, tests should set it. Run the script in a subshell with env -i plus the variables a case needs, and assert on the exit status and the output. Cover three cases for each required variable: unset, empty and valid. The unset and empty cases are where :? and ? differ, and a test pins the behaviour so a later edit does not weaken it by accident.

Where this does not apply

The environment is not a safe place for secrets. It is inherited by every child process, appears in /proc/PID/environ readable by the same user and root, and is often dumped by crash handlers and CI debug logs; reading secrets in a shell script covers alternatives such as files with mode 600. Variables cannot hold arrays, since the environment is a flat list of strings, and values with newlines survive only if every tool in the chain is careful. For many settings with structure, a config file is the better fit. The POSIX definition of environment variables lists the standard names and the rules for naming, and the twelve-factor config guidance explains the habit of configuring services through the environment.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.