Systemumgebung, .env-Datei und Kommandozeile sauber übereinanderlegen
Sobald ein Bash-Skript Konfiguration aus mehr als einer Quelle liest, entscheidet die Ladereihenfolge über das Verhalten in Produktion. Wer Systemumgebung, .env-Datei und Kommandozeilen-Overrides nicht bewusst priorisiert, bekommt Skripte, die auf dem eigenen Rechner funktionieren und beim Kollegen oder im CI-Runner plötzlich falsche Werte ziehen.
Inhaltsverzeichnis
- 1. Woher Umgebungsvariablen überhaupt kommen
- 2. Die richtige Reihenfolge: System, .env-Datei, Kommandozeile
- 3. Einen eigenen .env-Loader in reinem Bash schreiben
- 4. export vergessen: warum Kindprozesse plötzlich leere Variablen sehen
- 5. Anführungszeichen-Fallstricke in .env-Dateien
- 6. Kommandozeilen-Overrides als höchste Priorität durchreichen
- 7. Defaults als letzte Sicherheitsebene mit Parameter-Expansion
- 8. Pflichtvariablen validieren und kontrolliert abbrechen
- 9. Eigener Loader vs. bestehende Tools
- 10. Zusammenfassung
- 11. FAQ
1. Woher Umgebungsvariablen überhaupt kommen
Jeder Prozess unter Linux erbt seine Umgebungsvariablen von seinem Elternprozess, meist der Login-Shell. Diese Kette beginnt beim System-Login über /etc/environment und /etc/profile, setzt sich über Shell-Startdateien wie ~/.bashrc oder ~/.profile fort, und endet in der Umgebung, die ein konkretes Bash-Skript beim Start vorfindet. Ein Skript sieht also nie eine leere Umgebung, sondern immer das Ergebnis vieler vorheriger Schichten, die es selbst nicht kontrolliert.
Genau diese fehlende Kontrolle ist die Wurzel vieler Konfigurationsbugs: Eine Variable, die auf dem Entwicklerrechner zufällig über ~/.bashrc gesetzt ist, existiert auf dem CI-Runner oder im Docker-Container nicht, und ein Skript, das sich blind auf $API_URL verlässt, bricht dort mit einem leeren Wert statt mit einem klaren Fehler ab. Ein robustes Skript behandelt die geerbte Systemumgebung deshalb nur als unterste, am wenigsten vertrauenswürdige Schicht.
2. Die richtige Reihenfolge: System, .env-Datei, Kommandozeile
Eine bewährte Präzedenzregel für Deployment- und Automatisierungsskripte lautet: Systemumgebung zuerst als Basis laden, danach eine .env-Datei mit projektspezifischen Defaults darüberlegen, und zuletzt explizite Kommandozeilen-Übergaben (VAR=wert ./skript.sh oder ein --flag) als letzte, höchste Priorität anwenden. Diese Reihenfolge spiegelt wider, wie spezifisch eine Quelle ist: Je näher am konkreten Aufruf, desto mehr Vorrang bekommt sie.
Der Denkfehler, den viele Skripte machen, ist die umgekehrte Reihenfolge: Sie laden die .env-Datei zuletzt und überschreiben damit versehentlich einen bewussten Kommandozeilen-Override. Wer als Betreiber gezielt DRY_RUN=1 ./deploy.sh aufruft, erwartet, dass dieser Wert gewinnt, nicht dass eine .env-Datei ihn stillschweigend zurücksetzt. Die Reihenfolge beim Laden im Skript muss also exakt der gewünschten Präzedenz entsprechen, nicht umgekehrt.
3. Einen eigenen .env-Loader in reinem Bash schreiben
Für einfache Projekte reicht ein selbstgeschriebener Loader völlig aus und spart die Abhängigkeit von externen Tools. Die Kernidee: Die Datei zeilenweise lesen, Kommentarzeilen und Leerzeilen überspringen, und jede gültige SCHLUESSEL=WERT-Zeile nur dann als Umgebungsvariable exportieren, wenn sie nicht bereits in der aktuellen Umgebung gesetzt ist. Genau dieses Verhalten stellt sicher, dass eine vorher gesetzte Systemvariable oder ein Kommandozeilen-Override die .env-Datei nicht überschrieben bekommt.
Wichtig ist außerdem, dass der Loader keine beliebige Zeile blind mit eval ausführt, weil das die Datei effektiv zu ausführbarem Code macht und jede fehlerhafte oder böswillig manipulierte Zeile in der .env ein Sicherheitsrisiko wird. Der folgende Loader parst stattdessen strikt nach Muster und lehnt alles andere kommentarlos ab.
#!/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. export vergessen: warum Kindprozesse plötzlich leere Variablen sehen
Eine Variable, die nur mit VAR=wert ohne export gesetzt wird, existiert ausschließlich in der aktuellen Shell und wird niemals an Kindprozesse weitergegeben. Ruft ein Bash-Skript intern ein anderes Programm auf, etwa curl, python3 oder ein weiteres Skript, sieht dieser Kindprozess die Variable nicht, selbst wenn sie im aufrufenden Skript korrekt gesetzt und sogar mit echo ausgegeben werden kann. Das ist einer der häufigsten Gründe, warum eine Konfiguration im Skript funktioniert, aber in einem aufgerufenen Subprozess nicht ankommt.
Der Loader oben umgeht das Problem, indem er konsequent export verwendet, aber dieselbe Falle lauert auch bei manuell gesetzten Variablen im restlichen Skript. Eine einfache Faustregel hilft: Jede Variable, die auch nur potenziell an ein aufgerufenes Kommando weitergereicht werden könnte, gehört mit export gesetzt, selbst wenn sie im Moment nur lokal genutzt wird.
#!/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. Anführungszeichen-Fallstricke in .env-Dateien
Eine .env-Datei ist keine Bash-Datei, auch wenn sie ähnlich aussieht, und genau daraus entstehen die meisten Quoting-Fehler. Werte mit Leerzeichen müssen in Anführungszeichen stehen, sonst interpretiert ein naiver Parser nur den Teil bis zum ersten Leerzeichen als Wert. Der Loader oben liest die gesamte rechte Seite der Zeile inklusive Leerzeichen, was in den meisten Fällen praktisch ist, aber problematisch wird, sobald jemand tatsächlich Anführungszeichen mit in die Datei schreibt, weil diese dann als Teil des Werts landen statt entfernt zu werden.
Ein zweiter, subtilerer Fallstrick sind Kommentare am Zeilenende: PORT=8080 # Standardport wird von einem einfachen Parser komplett als Wert 8080 # Standardport übernommen, was in numerischen Kontexten zu kryptischen Fehlern führt. Wer Kommentare am Zeilenende erlauben will, muss den Parser explizit dafür erweitern, statt sich auf implizites Verhalten zu verlassen. Am sichersten bleibt die Konvention, Kommentare in .env-Dateien immer auf eigenen Zeilen zu platzieren und Werte mit Sonderzeichen konsequent in doppelte Anführungszeichen zu setzen.
# .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. Kommandozeilen-Overrides als höchste Priorität durchreichen
Damit ein Betreiber im Notfall gezielt einen einzelnen Wert übersteuern kann, ohne die .env-Datei anzufassen, sollte jedes Skript den klassischen Bash-Mechanismus unterstützen, Variablen direkt vor dem Aufruf zu setzen: LOG_LEVEL=debug ./deploy.sh. Diese Variablen landen bereits in der Umgebung, bevor das Skript überhaupt startet, weshalb der oben gezeigte Loader sie automatisch respektiert und nicht überschreibt, solange die Prüfung auf bereits gesetzte Variablen konsequent vor dem Setzen aus der .env steht.
Für Skripte mit expliziten Flags eignet sich zusätzlich getopts, um --log-level debug in dieselbe Variable zu schreiben, aber mit export, damit auch hier Kindprozesse den Wert sehen. Wichtig ist, diese explizite Flag-Verarbeitung tatsächlich nach dem Laden der .env-Datei auszuführen, damit ein Flag auf der Kommandozeile immer das letzte Wort hat, unabhängig davon, was zuvor aus Datei oder Systemumgebung kam.
#!/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 als letzte Sicherheitsebene mit Parameter-Expansion
Selbst nach System, .env und Kommandozeile bleibt eine vierte, unterste Ebene sinnvoll: eingebaute Defaults direkt im Skript, damit ein fehlender Wert nicht zu einem kryptischen Laufzeitfehler führt, sondern zu einem sinnvollen Standardverhalten oder einer klaren Fehlermeldung. Bash bietet dafür die Parameter-Expansion ${VAR:-default}, die genau dann den Default einsetzt, wenn VAR leer oder nicht gesetzt ist, ohne die Variable selbst zu verändern.
Für Variablen, die zwingend gesetzt sein müssen und für die kein sinnvoller Default existiert, ist die verwandte Form ${VAR:?Fehlermeldung} die bessere Wahl, weil sie das Skript sofort mit einer verständlichen Meldung abbricht, statt mit einem leeren String weiterzulaufen und den Fehler erst viele Zeilen später sichtbar zu machen. Diese beiden Formen zusammen decken die meisten praktischen Fälle ab, ohne dass ein Skript für jede Variable eine eigene if-Prüfung schreiben muss.
8. Pflichtvariablen validieren und kontrolliert abbrechen
Für Skripte mit mehreren Pflichtvariablen lohnt sich eine zentrale Validierungsfunktion, die alle erforderlichen Namen in einer Liste durchgeht und am Ende gesammelt meldet, welche fehlen, statt bei der ersten fehlenden Variable abzubrechen und den Rest im Dunkeln zu lassen. Das spart dem Betreiber mehrere Anläufe, in denen jedes Mal nur eine einzelne fehlende Variable auftaucht.
Diese Validierung sollte so früh wie möglich im Skript stehen, direkt nach dem Laden von .env und der Verarbeitung von Kommandozeilen-Flags, aber noch vor jeder eigentlichen Aktion wie einem Deployment oder einem Datenbankzugriff. So verhindert das Skript, dass es mitten in einer riskanten Operation abbricht, nur weil eine Variable am Anfang übersehen wurde.
#!/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. Eigener Loader vs. bestehende Tools
Der selbstgeschriebene Loader aus diesem Artikel ist bewusst minimal gehalten und deckt die häufigsten Fälle ab, ohne eine zusätzliche Abhängigkeit einzuführen. Für größere Projekte mit vielen Umgebungen, verschachtelten Konfigurationsdateien oder dem Bedürfnis nach automatischem Wechsel beim Verzeichniswechsel lohnt sich der Blick auf etablierte Werkzeuge, die genau diese Präzedenzregeln bereits robust umsetzen und zusätzliche Komfortfunktionen mitbringen.
| Mechanismus | Präzedenz | Persistenz | Typischer Einsatz |
|---|---|---|---|
| Systemumgebung | Niedrigste | Bis zum Logout/Neustart | Globale Defaults, PATH, Locale |
| .env-Datei | Mittel | Bis zur nächsten Änderung der Datei | Projektspezifische Konfiguration |
CLI-Override (VAR=wert) |
Hoch | Nur für diesen Aufruf | Einmalige Ausnahme, Debugging |
direnv |
Automatisch je Verzeichnis | Bis zum Verzeichniswechsel | Mehrere Projekte mit eigener Konfiguration |
Eingebauter Default (${VAR:-x}) |
Niedrigste | Fest im Skript | Fallback, falls nichts anderes gesetzt ist |
Mironsoft
Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur
Shell-Skripte, die in der Produktion zuverlässig laufen?
Wir analysieren bestehende Bash-Skripte, erkennen fragile Muster und ersetzen sie durch robuste Bash-Patterns: mit vollständiger Fehlerbehandlung, Logging und sicherer Parallelisierung für euren Deployment-Stack.
Code-Review
ShellCheck-Analyse und manuelle Prüfung auf kritische Bash-Pattern-Verstöße.
Refactoring
Fehlerbehandlung, Logging und sichere Dateioperationen nachrüsten.
CI-Integration
ShellCheck und BATS in Pipelines integrieren und Regressionstests aufbauen.
10. Zusammenfassung
Umgebungsvariablen-Präzedenz in Bash: Das Wichtigste auf einen Blick
Reihenfolge
System zuerst laden, dann .env darüberlegen, zuletzt Kommandozeilen-Overrides anwenden. Der Loader darf bereits gesetzte Variablen niemals überschreiben.
export nicht vergessen
Nur exportierte Variablen sind für Kindprozesse wie curl oder aufgerufene Skripte sichtbar, sonst bleiben sie in der aktuellen Shell gefangen.
Quoting in .env
Werte mit Leerzeichen in Anführungszeichen setzen, Kommentare immer auf eigenen Zeilen, nie als Trailing-Kommentar hinter einem Wert.
Validierung zuerst
Pflichtvariablen mit require_vars direkt nach dem Laden prüfen und gesammelt melden, bevor irgendeine riskante Aktion beginnt.