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.
Table of Contents
- 1. Where environment variables actually come from
- 2. The correct order: system, .env file, command line
- 3. Writing a custom .env loader in plain Bash
- 4. Forgetting export: why child processes suddenly see nothing
- 5. Quoting pitfalls in .env files
- 6. Passing command-line overrides through as top priority
- 7. Defaults as a final safety net through parameter expansion
- 8. Validating required variables and failing in a controlled way
- 9. Custom loader vs. existing tools
- 10. Summary
- 11. FAQ
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.