Environment Variable Precedence and Loading .env Files in Bash
AI generated
$_
#!/
Bash · Configuration · Environment Variables · Deployment
Environment Variable Precedence in Bash
Layering system environment, .env files and CLI overrides correctly

As soon as a Bash script reads configuration from more than one source, the load order determines its behavior in production. Scripts that do not deliberately prioritize system environment, .env file and command-line overrides tend to work fine on one machine and silently pick up the wrong value on a colleague's laptop or in a CI runner.

16 min read export · .env · quoting Bash 4.x · 5.x · POSIX-adjacent

1. Where environment variables actually come from

Every process on Linux inherits its environment variables from its parent process, usually the login shell. That chain starts at system login through /etc/environment and /etc/profile, continues through shell startup files like ~/.bashrc or ~/.profile, and ends in whatever environment a given Bash script finds when it starts. A script never sees an empty environment, it always sees the result of many prior layers it does not control itself.

That lack of control is exactly where most configuration bugs originate: a variable that happens to be set in ~/.bashrc on a developer's machine simply does not exist on a CI runner or inside a Docker container, and a script that blindly relies on $API_URL fails there with an empty value instead of a clear error. A robust script treats the inherited system environment as the lowest, least trustworthy layer.

2. The correct order: system, .env file, command line

A proven precedence rule for deployment and automation scripts is: load the system environment first as a base, then layer a .env file with project-specific defaults on top, and finally apply explicit command-line assignments (VAR=value ./script.sh or a --flag) as the last, highest priority. This order mirrors how specific a source is: the closer to the concrete invocation, the more it should be allowed to win.

The mistake many scripts make is doing this backwards: loading the .env file last and thereby accidentally overwriting a deliberate command-line override. An operator who runs DRY_RUN=1 ./deploy.sh expects that value to win, not to have a .env file silently reset it. The load order inside the script must therefore exactly match the intended precedence, not the reverse.

3. Writing a custom .env loader in plain Bash

For simple projects a hand-written loader is entirely sufficient and avoids an external dependency. The core idea: read the file line by line, skip comment lines and blank lines, and only export a valid KEY=VALUE line as an environment variable if it is not already set in the current environment. That single check is what guarantees a previously set system variable or a CLI override never gets clobbered by the .env file.

It is also important that the loader never blindly runs a line through eval, because that effectively turns the file into executable code, and any malformed or maliciously crafted line in the .env becomes a security risk. The loader below instead parses strictly against a pattern and silently rejects anything else.


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

# load_dotenv: reads KEY=VALUE lines from a .env file and exports them
# ONLY if the variable is not already set in the current environment.
# This preserves the precedence: system env / CLI override > .env file.
load_dotenv() {
  local env_file="${1:-.env}"
  [[ -f "$env_file" ]] || return 0

  local line key value
  while IFS= read -r line || [[ -n "$line" ]]; do
    # skip blank lines and comments
    [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue

    # only accept strict KEY=VALUE, reject anything else
    if [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
      key="${BASH_REMATCH[1]}"
      value="${BASH_REMATCH[2]}"

      # already set by system env or CLI override wins -- do not touch it
      if [[ -z "${!key+x}" ]]; then
        export "$key=$value"
      fi
    fi
  done < "$env_file"
}

load_dotenv ".env"
echo "API_URL=${API_URL:-not set}"

4. Forgetting export: why child processes suddenly see nothing

A variable set only with VAR=value, without export, exists exclusively inside the current shell and is never passed to child processes. If a Bash script internally calls another program, say curl, python3, or a further script, that child process does not see the variable, even though the calling script sets it correctly and can even print it with echo. This is one of the most common reasons configuration works inside the script but never arrives inside a called subprocess.

The loader above sidesteps the problem by consistently using export, but the same trap lurks with any manually set variable elsewhere in the script. A simple rule of thumb helps: any variable that could potentially be passed to a called command belongs behind export, even if it is only used locally for the moment.


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

# WRONG: only visible inside this shell, curl never sees it
API_TOKEN="secret-123"
curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com  # works here

# still WRONG when calling an external script -- it will NOT see API_TOKEN
./call-api.sh

# RIGHT: export makes it part of the environment passed to child processes
export API_TOKEN="secret-123"
./call-api.sh

5. Quoting pitfalls in .env files

A .env file is not a Bash file, even though it looks similar, and that mismatch is where most quoting mistakes come from. Values containing spaces must be quoted, otherwise a naive parser only treats the text up to the first space as the value. The loader above reads the entire right-hand side of the line including spaces, which is convenient in most cases, but becomes tricky the moment someone actually writes quote characters into the file, since those then end up as part of the value instead of being stripped.

A second, subtler trap is trailing comments: PORT=8080 # default port gets consumed whole by a simple parser as the value 8080 # default port, which produces cryptic errors in numeric contexts. Anyone who wants to allow trailing comments needs to extend the parser explicitly rather than relying on implicit behavior. The safest convention is to always place comments in .env files on their own line and to consistently wrap values containing special characters in double quotes.


# .env -- correct quoting conventions

# comments belong on their own line, never trailing after a value
DATABASE_URL="postgres://user:pass@localhost:5432/app"
FEATURE_LABEL="Black Friday Sale"

# no spaces around the = sign -- "PORT = 8080" would break strict parsers
PORT=8080

# WRONG: trailing comment becomes part of the value with a naive parser
# TIMEOUT=30 # seconds

6. Passing command-line overrides through as top priority

So an operator can override a single value in an emergency without touching the .env file, every script should support the classic Bash mechanism of setting a variable right before invocation: LOG_LEVEL=debug ./deploy.sh. Those variables land in the environment before the script even starts, which is why the loader shown above automatically respects them and never overwrites them, as long as the already-set check runs consistently before assigning from the .env file.

For scripts with explicit flags, getopts is a good fit for writing --log-level debug into the same variable, but with export, so child processes see the value too. It is important to run this explicit flag processing after loading the .env file, so a command-line flag always has the final word regardless of what came earlier from the file or the system environment.


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

load_dotenv ".env"

# explicit CLI flags always win -- processed AFTER the .env file
while [[ $# -gt 0 ]]; do
  case "$1" in
    --log-level) export LOG_LEVEL="$2"; shift 2 ;;
    --dry-run)   export DRY_RUN="1"; shift ;;
    *) echo "Unknown flag: $1" >&2; exit 1 ;;
  esac
done

echo "LOG_LEVEL=${LOG_LEVEL:-info}, DRY_RUN=${DRY_RUN:-0}"

7. Defaults as a final safety net through parameter expansion

Even after system, .env and command line, a fourth, lowest layer is still worthwhile: built-in defaults right inside the script, so a missing value leads to sensible fallback behavior or a clear error instead of a cryptic runtime failure. Bash offers the parameter expansion ${VAR:-default} for exactly this, substituting the default only when VAR is empty or unset, without modifying the variable itself.

For variables that absolutely must be set and for which no sensible default exists, the related form ${VAR:?error message} is the better choice, because it aborts the script immediately with an understandable message instead of continuing with an empty string and only surfacing the failure many lines later. Together, these two forms cover most practical cases without needing a dedicated if check for every single variable.

8. Validating required variables and failing in a controlled way

For scripts with several required variables, a central validation function pays off: it walks through a list of required names and reports, collectively, at the end which ones are missing, instead of aborting on the first missing variable and leaving the rest hidden in the dark. That saves the operator several attempts where only one missing variable surfaces at a time.

This validation should run as early as possible in the script, right after loading .env and processing command-line flags, but before any actual action like a deployment or a database access. That way the script never aborts in the middle of a risky operation just because a variable at the top was overlooked.


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

require_vars() {
  local missing=()
  local var
  for var in "$@"; do
    if [[ -z "${!var:-}" ]]; then
      missing+=("$var")
    fi
  done
  if (( ${#missing[@]} > 0 )); then
    printf 'Missing required variables: %s\n' "${missing[*]}" >&2
    exit 1
  fi
}

require_vars API_URL API_TOKEN DEPLOY_TARGET

9. Custom loader vs. existing tools

The custom loader in this article is deliberately minimal and covers the most common cases without introducing an extra dependency. For larger projects with many environments, nested configuration files, or a need to switch configuration automatically on directory change, it is worth looking at established tools that already implement these precedence rules robustly and bring extra convenience features along.

Mechanism Precedence Persistence Typical use
System environment Lowest Until logout/reboot Global defaults, PATH, locale
.env file Medium Until the file changes Project-specific configuration
CLI override (VAR=value) High For this invocation only One-off exception, debugging
direnv Automatic per directory Until directory change Multiple projects with their own configuration
Built-in default (${VAR:-x}) Lowest Hard-coded in the script Fallback if nothing else is set

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

Environment Variable Precedence in Bash: The Essentials at a Glance

Load order

Load system first, then layer .env on top, apply command-line overrides last. The loader must never overwrite a variable that is already set.

Do not forget export

Only exported variables are visible to child processes like curl or called scripts, otherwise they stay trapped in the current shell.

Quoting in .env

Wrap values with spaces in quotes, keep comments on their own lines, never as a trailing comment after a value.

Validate first

Check required variables with require_vars right after loading, and report them collectively before any risky action begins.

11. FAQ: Environment Variable Precedence in Bash: The Essentials at a Glance

1In what order should a Bash script load configuration?
First the inherited system environment as a base, then a .env file with defaults, and finally explicit command-line overrides. Each later layer may overwrite earlier values, but the loader itself must never touch a variable that is already set.
2Why does my called script not see a variable even though it is set?
You probably forgot export. A variable without export only exists inside the current shell and is not passed to child processes.
3Do I need a custom .env loader for every project?
Not necessarily. For simple projects a short, hand-written loader like the one in this article is entirely sufficient. For more complex setups an established tool like direnv is worth adopting.
4How do I prevent a .env file from overwriting a command-line override?
The loader must check whether the variable already exists in the environment before assigning it, for example with the Bash test ${!key+x}, and skip it instead of overwriting it.
5Why should I avoid eval when loading .env lines?
eval executes arbitrary text as Bash code. A malformed or manipulated line in the .env file could then execute arbitrary code in the script's context, which is a significant security risk.
6How do I correctly handle spaces in .env values?
Wrap values containing spaces in double quotes. A parser that reads the entire right-hand side of the line as the value handles this fine, as long as the quotes themselves are handled consistently.
7Can I write a comment after a value on the same line?
Better not to. A simple parser absorbs the comment as part of the value. Comments belong on their own line above the relevant entry.
8How do I enforce that a variable must be set?
With the parameter expansion ${VAR:?error message}, the script aborts immediately with an understandable message if the variable is missing, instead of continuing with an empty value.
9What is the benefit of a central require_vars function?
It collects all missing required variables and reports them at once, instead of aborting on the first missing one and only revealing the remaining problems on the next attempt.
10When is a tool like direnv worth it over a custom loader?
Once multiple projects with different configuration exist side by side and the environment needs to switch automatically on directory change, direnv handles that logic more reliably than a hand-written loader.