Bash-Skripting für Admins – die Grundlagen
Bash-Skripting ist das Schweizer Taschenmesser jedes Linux-Admins. Diese Anleitung zeigt die Grundlagen: Shebang, Variablen und Quoting, Positionsparameter, Bedingungen, Schleifen, Funktionen, Exit-Codes und den robusten Strict Mode – plus zwei Mini-Skripte für Backup und Healthcheck.

Wer Linux-Server betreut, kommt an Bash-Skripting nicht vorbei: wiederkehrende Aufgaben automatisieren, Backups wegschreiben, Dienste prüfen, alles per Cron oder systemd-Timer planen. Diese Anleitung bringt dir die Grundlagen praxisnah bei – Shebang, Variablen und Quoting, Positionsparameter, Bedingungen, Schleifen, Funktionen, Exit-Codes und den robusten set -euo pipefail. Am Ende baust du zwei kleine, aber produktionstaugliche Skripte: einen Backup-Wrapper und einen Healthcheck. Alle Beispiele laufen auf Debian 12, Ubuntu 24.04 LTS und in Docker-Containern und sind copy-paste-fertig.
Kurzfassung: Erste Zeile #!/usr/bin/env bash, danach set -euo pipefail für robuste Skripte. Variablen immer in doppelte Anführungszeichen setzen ("$var"), zum Iterieren "$@" statt $*. Bedingungen mit [[ ... ]] und Dateitests wie -f, -d, -x. Exit-Code des letzten Befehls steht in $?; Fehler nach stderr mit >&2 und passendem exit. Command Substitution mit $(...). Für Cron/systemd: absolute Pfade, eigenes Logging, Temp-Dateien mit mktemp plus trap ... EXIT, Überlappung per flock verhindern. Skripte vor dem Ausrollen mit shellcheck prüfen.
Voraussetzungen
Für diese Anleitung brauchst du nur wenig Vorbereitung:
- Ein Linux-System mit Bash: Debian 12, Ubuntu 24.04 LTS oder ein Container. Bash ist dort vorinstalliert; prüfe die Version mit
bash --version. - Shell-Zugang und ein Texteditor: z. B.
nanoodervim. Skripte legst du als.sh-Datei an und machst sie mitchmod +xausführbar. - shellcheck (empfohlen): der statische Linter für Shell-Skripte deckt einen Großteil typischer Fehler ab. Installation:
sudo apt install shellcheck. - Grundkenntnisse der Shell: du solltest dich auf der Kommandozeile bewegen können (Verzeichnisse, Dateien, Pipes).
Wichtig für Debian/Ubuntu: /bin/sh zeigt dort auf dash, nicht auf Bash. Sobald du Bash-Features wie Arrays, [[ ... ]], local oder == nutzt, musst du Bash als Interpreter erzwingen – siehe Schritt 1.
Schritt 1: Shebang setzen und das Skript ausführbar machen
Die allererste Zeile eines Skripts, der Shebang, bestimmt den Interpreter. Für portable Bash-Skripte ist #!/usr/bin/env bash die beste Wahl: Damit wird bash über den PATH gesucht, was auf Systemen wie macOS/Homebrew, FreeBSD oder NixOS zuverlässiger ist als der feste Pfad #!/bin/bash.
#!/usr/bin/env bash
# Shebang in Zeile 1: portable bash-Suche ueber PATH
echo "Hallo von Bash"Speichere das als hallo.sh, mach es ausführbar und starte es:
chmod +x hallo.sh
./hallo.shNimm niemals#!/bin/sh, wenn du Bash-Features brauchst. Auf Debian/Ubuntu ist/bin/shgleichdash, und bash-only Konstrukte wie Arrays,[[ ... ]],localoder==schlagen dann fehl.
Schritt 2: Robusten Strict Mode aktivieren
Direkt nach dem Shebang gehört in jedes ernsthafte Skript der Strict Mode. Drei Optionen machen Skripte deutlich robuster:
#!/usr/bin/env bash
set -euo pipefail
# -e : Abbruch, sobald ein Befehl mit Exit-Code ungleich 0 endet
# -u : Abbruch bei Referenz auf eine undefinierte Variable
# pipefail: eine Pipeline liefert den Fehlercode, sobald IRGENDEIN Glied scheitertOptionLangformWirkung
-e
errexit
Skript bricht sofort ab, wenn ein Befehl einen Exit-Code ungleich 0 liefert.
-u
nounset
Zugriff auf eine nicht gesetzte Variable führt zum Abbruch (deckt Tippfehler auf).
-o pipefail
pipefail
Eine Pipeline schlägt fehl, sobald irgendein Glied fehlschlägt – nicht nur das letzte.
Optional kannst du zusätzlich den Internal Field Separator härten. IFS=$'\n\t' verhindert Word-Splitting an einfachen Leerzeichen und macht die Verarbeitung von Dateinamen mit Leerzeichen sicherer:
IFS=$'\n\t' # optional: sicheres Field-Separator, verhindert Word-Splitting an LeerzeichenVorsicht, kein Allheilmittel: set -e hat Tücken. Befehle in if- oder while-Bedingungen, mit || verkettet oder unter bestimmten Umständen in Funktionen lösen keinen Abbruch aus. Setze set -e also nicht blind ein, sondern kenne die Nebenwirkungen. Und set -u bricht bei optionalen Variablen ab – dafür gibt es eine saubere Lösung im nächsten Schritt.
Schritt 3: Variablen und Quoting beherrschen
Das häufigste Fehlerfeld in Shell-Skripten ist falsches Quoting. Merke dir die zwei Anführungszeichen-Arten:
- Doppelte Anführungszeichen (
"...") erlauben Variablen- und Dollar-Expansion und bewahren Leerzeichen. Das ist fast immer das, was du willst. - Einfache Anführungszeichen (
'...') nehmen den Inhalt komplett literal – keine Expansion, kein$.
name="Welt"
echo "Hallo $name" # Ausgabe: Hallo Welt (Expansion in doppelten Anfuehrungszeichen)
echo 'Hallo $name' # Ausgabe: Hallo $name (literal in einfachen Anfuehrungszeichen)Die goldene Regel: Setze Variablen im Zweifel immer in doppelte Anführungszeichen. Unquoted Variablen werden Word-Splitting und Globbing unterzogen – ein Pfad mit Leerzeichen oder ein * im Wert führt sonst zu schwer auffindbaren Fehlern.
datei="mein bericht.txt"
rm "$datei" # richtig: eine Datei "mein bericht.txt"
rm $datei # falsch: versucht 'mein' und 'bericht.txt' zu loeschenFür optionale Variablen umgehst du den set -u-Abbruch mit der Default-Wert-Expansion über Doppelpunkt-Minus:
ziel="${1:-/tmp}" # nimmt /tmp, wenn $1 leer oder nicht gesetzt ist
echo "Ziel: $ziel"Schritt 4: Positionsparameter und Sondervariablen nutzen
Argumente, die dein Skript beim Aufruf bekommt, landen in den Positionsparametern. Die wichtigsten:
ParameterBedeutung
$0
Name des Skripts
$1 … $9
erstes bis neuntes Argument (ab dem 10. mit geschweiften Klammern: ${10})
$#
Anzahl der Argumente (ohne $0)
"$@"
alle Argumente, jedes als eigenes, korrekt gequotetes Wort
"$*"
alle Argumente zu EINEM Wort verbunden, getrennt durch das erste Zeichen von IFS
Zum Iterieren über Argumente nimmst du immer "$@" in doppelten Anführungszeichen. Nur so bleiben Argumentgrenzen bei Leerzeichen erhalten:
for f in "$@"; do
echo "Argument: $f"
doneVerwendest du$*statt"$@", zerstörst du die Argumentgrenzen, sobald ein Argument ein Leerzeichen enthält. Zum Iterieren ist"$@"fast immer richtig.
Daneben gibt es nützliche Sondervariablen:
VariableInhalt
$?
Exit-Code des zuletzt ausgeführten Befehls (0 = Erfolg, ungleich 0 = Fehler)
$$
PID der aktuellen Shell
$!
PID des letzten Hintergrundprozesses
Schritt 5: Bedingungen, Dateitests und Schleifen schreiben
Für Bedingungen nutzt du in Bash-Skripten am besten das Keyword [[ ... ]]. Es ist kein externes Kommando wie [ bzw. test, macht kein Word-Splitting oder Globbing auf seinen Argumenten und ist damit sicherer bei Leerzeichen. Es unterstützt && und ||, == mit Glob-Mustern sowie =~ für Regex. (Für reine POSIX-Portabilität wäre [/test nötig – in Bash-Skripten ist [[ ... ]] die Empfehlung.)
Die wichtigsten Datei- und String-Tests:
Testwahr, wenn …
-e PFAD
Pfad existiert
-f PFAD
reguläre Datei
-d PFAD
Verzeichnis
-r / -w / -x
lesbar / schreibbar / ausführbar
-s PFAD
Datei nicht leer (Größe größer 0)
-L PFAD
Symlink
-z STR / -n STR
String leer / String nicht leer
Zahlen vergleichst du mit -eq -ne -lt -le -gt -ge, Strings mit = bzw. == und !=. Ein paar typische Bedingungen:
if [[ -d "$DIR" ]]; then
echo "Verzeichnis existiert: $DIR"
fi
if [[ ! -f "$KONFIG" ]]; then
echo "Konfig fehlt: $KONFIG" >&2
exit 1
fi
if [[ "$anzahl" -gt 10 ]]; then
echo "mehr als 10"
fiDie zwei wichtigsten Schleifen sind for und while. Eine for-Schleife läuft über eine Liste, eine while-Schleife liest etwa eine Datei zeilenweise. Beim zeilenweisen Lesen ist IFS= read -r line Pflicht: -r verhindert die Interpretation von Backslashes, IFS= das Trimmen von Leerzeichen am Zeilenrand.
# for-Schleife ueber eine feste Liste
for dienst in nginx mariadb cron; do
echo "pruefe $dienst"
done
# while-Schleife: Datei zeilenweise lesen (sicher mit -r)
while IFS= read -r line; do
echo "Zeile: $line"
done < datei.txtSchritt 6: Funktionen, Exit-Codes und Fehlerprüfung
Funktionen bündeln wiederkehrende Logik. Variablen innerhalb einer Funktion machst du mit local lokal, damit sie nicht versehentlich globalen Zustand überschreiben. Eine kleine Logging-Funktion ist in fast jedem Skript nützlich:
log() {
printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "$2"
}
# Aufruf: log INFO "Backup gestartet"
backup_dir() {
local src="$1" dst="$2"
tar -czf "$dst" -C "$src" .
}
# Aufruf: backup_dir /etc /tmp/etc.tar.gzJeder Befehl liefert beim Beenden einen Exit-Code: 0 für Erfolg, alles andere für einen Fehler. Den Code des letzten Befehls findest du in $?. So wertest du ihn gezielt aus:
cmd
rc=$?
if [[ $rc -ne 0 ]]; then
echo "Fehler rc=$rc" >&2
exit "$rc"
fiEine einfache Argument- und Fehlerprüfung am Anfang des Skripts erspart später viel Ärger. Schreibe Fehlermeldungen nach stderr (>&2) und beende mit einem Exit-Code ungleich 0:
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <ziel>" >&2
exit 1
fi
DIR="$1"
if [[ ! -d "$DIR" ]]; then
echo "Verzeichnis fehlt: $DIR" >&2
exit 1
fiFür Ausgaben und Werte aus anderen Befehlen nutzt du die Command Substitution mit $(...). Sie ist gegenüber den alten Backticks zu bevorzugen – verschachtelbar und besser lesbar. Das Ergebnis setzt du immer in doppelte Anführungszeichen:
out="$(date +%F)" # Command Substitution mit Dollar-Klammern statt Backticks
echo "Datum heute: $out"
anzahl="$(ls -1 "$DIR" | wc -l)"Schritt 7: Mini-Skript 1 – Backup-Wrapper
Jetzt setzt du das Gelernte zu einem ersten praxistauglichen Skript zusammen: einem Backup-Wrapper, der ein Verzeichnis in ein komprimiertes Archiv packt. Es nutzt Strict Mode, Argumentprüfung, Logging, eine sichere Temp-Datei mit mktemp und garantiertes Aufräumen per trap ... EXIT sowie absolute Pfade – damit ist es Cron- und systemd-tauglich.
#!/usr/bin/env bash
set -euo pipefail
# --- Konfiguration (absolute Pfade fuer Cron/systemd) ---
BACKUP_ROOT="/var/backups/myapp"
LOGFILE="/var/log/backup.log"
log() {
printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "$2" >> "$LOGFILE"
}
# --- Argumentpruefung ---
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <quell-verzeichnis>" >&2
exit 1
fi
SRC="$1"
if [[ ! -d "$SRC" ]]; then
echo "Quelle ist kein Verzeichnis: $SRC" >&2
exit 1
fi
# --- sichere Temp-Datei plus garantierte Aufraeumung ---
TMPFILE="$(mktemp)"
trap 'rm -f "$TMPFILE"' EXIT
# --- Backup erstellen ---
mkdir -p "$BACKUP_ROOT"
STAMP="$(date +%F_%H%M%S)"
DST="$BACKUP_ROOT/backup_$STAMP.tar.gz"
log INFO "Starte Backup von $SRC nach $DST"
if tar -czf "$TMPFILE" -C "$SRC" .; then
mv "$TMPFILE" "$DST"
trap - EXIT # Erfolg: kein Cleanup mehr noetig
log INFO "Backup erfolgreich: $DST"
else
rc=$?
log ERROR "tar fehlgeschlagen rc=$rc"
exit "$rc"
fiDas Muster mktemp plus trap 'rm -f "$TMPFILE"' EXIT ist wichtig: Der EXIT-Trap feuert auch bei Ctrl-C oder SIGTERM und räumt die Temp-Datei zuverlässig weg. Einzige Ausnahme ist kill -9 (SIGKILL), das nicht abfangbar ist. Lege Temp-Dateien nie mit festen, vorhersagbaren Namen an (Race- und Symlink-Risiko) – immer mktemp.
Schritt 8: Mini-Skript 2 – Healthcheck
Das zweite Skript ist ein Healthcheck, der mehrere Dienste prüft und mit einem passenden Exit-Code endet – ideal für Monitoring oder als systemd-Service. Es nutzt eine for-Schleife über die Dienste, $?-Auswertung und gibt einen Gesamt-Exit-Code zurück.
#!/usr/bin/env bash
set -euo pipefail
LOGFILE="/var/log/healthcheck.log"
DIENSTE=("nginx" "mariadb" "cron")
log() {
printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "$2" | tee -a "$LOGFILE"
}
fehler=0
for dienst in "${DIENSTE[@]}"; do
if systemctl is-active --quiet "$dienst"; then
log OK "$dienst laeuft"
else
log FAIL "$dienst laeuft NICHT"
fehler=$((fehler + 1))
fi
done
if [[ $fehler -gt 0 ]]; then
log ERROR "$fehler Dienst(e) nicht aktiv"
exit 1
fi
log INFO "Alle Dienste aktiv"
exit 0Damit nie zwei Läufe gleichzeitig anlaufen (etwa wenn ein Lauf länger dauert als das Intervall), sicherst du das Skript mit flock ab. Setze diese zwei Zeilen direkt nach dem Strict Mode ein:
exec 200>/run/lock/healthcheck.lock
flock -n 200 || { echo 'laeuft bereits' >&2; exit 1; }Bevor du eines dieser Skripte ausrollst, jagst du es einmal durch shellcheck. Der Linter findet Quoting-Fehler, fragwürdige Konstrukte und vieles mehr:
shellcheck ./backup.sh ./healthcheck.shSchritt 9: Skripte Cron- und systemd-tauglich machen
Skripte, die manuell laufen, scheitern oft in Cron – der Klassiker unter den Stolperfallen. Der Grund: Cron läuft in einer minimalen Umgebung. Der PATH ist nicht dein interaktiver PATH, und relative Pfade laufen ins Leere. Die Lösung: absolute Pfade für Binaries und Dateien verwenden und im Crontab den Kopf setzen.
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO="admin@example.com"
# Backup taeglich um 02:00 Uhr, stdout+stderr ins Logfile
0 2 * * * /usr/local/bin/backup.sh /etc >> /var/log/backup.log 2>&1Wichtig beim Logging: Cron schickt Ausgaben standardmäßig per Mail an den lokalen Mailspool, der selten gelesen wird. Leite deshalb stdout und stderr mit >> (anhängen) und 2>&1 in ein Logfile um. Ein MAILTO oben sorgt zusätzlich dafür, dass echte Fehlerausgaben dich erreichen.
Das Prozent-Zeichen hat in Crontab-Kommandos Sonderbedeutung (es wirkt als Zeilenumbruch bzw.stdin). Datumsformate wie%Fmusst du im Crontab mit einem Backslash escapen (\%F) – oder du kapselst sie sauber im Skript, wie in unseren Beispielen.
Als moderne Alternative bietet sich ein systemd-Timer an. Er braucht immer zwei Units: eine .service-Unit mit der Aufgabe und eine .timer-Unit mit dem Zeitplan. Vorteile gegenüber Cron sind zentrales Logging über journalctl und saubere Abhängigkeiten.
# /etc/systemd/system/backup.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh /etc
# /etc/systemd/system/backup.timer
[Timer]
OnCalendar=Mon..Fri 22:30
Persistent=true
[Install]
WantedBy=timers.targetAktiviert wird der Timer mit einem Befehl:
sudo systemctl enable --now backup.timerOnCalendar definiert den Zeitpunkt, Persistent=true holt verpasste Läufe nach (etwa nach einem Reboot oder Standby) und Type=oneshot markiert eine einmalige Aufgabe. Lässt du Persistent=true weg, werden verpasste Läufe nicht nachgeholt – für Backups meist unerwünscht.
Troubleshooting
- Skript läuft im Terminal, scheitert in Cron: fast immer relative Pfade oder die Annahme des interaktiven
PATH. Verwende absolute Pfade und setzeSHELL/PATHim Crontab. unbound variable-Abbruch durchset -u: eine optionale Variable ist nicht gesetzt. Nutze die Default-Wert-Expansion"${VAR:-default}". Die Parameter$@und$*sind von-uausgenommen.set -ebricht nicht ab, wo du es erwartest: Befehle inif/while-Bedingungen, mit||verkettet oder teils in Funktionen lösen keinen Abbruch aus. Prüfe in solchen Fällen den Exit-Code explizit über$?.- Dateinamen mit Leerzeichen zerreißen: du hast eine Variable ohne doppelte Anführungszeichen verwendet oder
$*statt"$@". Quote konsequent und iteriere mit"$@". - Müll-Dateien in
/tmp:mktempohne Cleanup-Trap. Ergänzetrap 'rm -f "$TMPFILE"' EXITdirekt nach demmktemp. - Cleanup läuft nicht in einer Subshell/Pipeline: der Trap im Hauptskript greift dort nicht automatisch. Ressourcen-erzeugende Subshells brauchen einen eigenen Trap.
kill -9(SIGKILL) ist generell nicht abfangbar. - systemd-Timer holt verpasste Läufe nicht nach: es fehlt
Persistent=truein der.timer-Unit. Ergänzen und mitsystemctl daemon-reloadsowie erneutem Aktivieren übernehmen. - Bash-Feature schlägt fehl: wahrscheinlich läuft das Skript unter
dash, weil der Shebang fehlt oder#!/bin/shlautet. Setze#!/usr/bin/env bash.
Häufige Fragen
Warum #!/usr/bin/env bash statt #!/bin/bash?
Mit #!/usr/bin/env bash wird bash über den PATH gesucht, das ist portabler – etwa auf macOS/Homebrew, FreeBSD oder NixOS, wo Bash nicht zwingend unter /bin/bash liegt. Auf reinen Debian/Ubuntu-Servern funktionieren beide; env ist die robustere Standardwahl.
Was genau bewirkt set -euo pipefail?
-e bricht das Skript beim ersten Befehl mit Exit-Code ungleich 0 ab, -u beim Zugriff auf eine undefinierte Variable, und pipefail lässt eine Pipeline schon fehlschlagen, wenn irgendein Glied scheitert – nicht erst das letzte. Zusammen machen sie Skripte deutlich robuster für den Produktivbetrieb.
Wann doppelte und wann einfache Anführungszeichen?
Doppelte Anführungszeichen ("..."), wenn Variablen expandiert werden sollen und Leerzeichen erhalten bleiben müssen – das ist der Normalfall. Einfache Anführungszeichen ('...'), wenn der Inhalt völlig literal bleiben soll, ohne jede Expansion.
Was ist der Unterschied zwischen "$@" und "$*"?
"$@" expandiert jedes Argument als eigenes, korrekt gequotetes Wort – ideal zum Iterieren. "$*" verbindet alle Argumente zu einem einzigen Wort, getrennt durch das erste Zeichen von IFS. Bei Argumenten mit Leerzeichen brauchst du fast immer "$@".
Brauche ich [[ ... ]] oder reicht [ ... ]?
[[ ... ]] ist ein Bash-Keyword: kein Word-Splitting oder Globbing auf den Argumenten, sicherer bei Leerzeichen, und es kann &&, ||, Glob-Muster mit == sowie Regex mit =~. [ bzw. test ist POSIX-portabel. Für reine Bash-Skripte ist [[ ... ]] die Empfehlung.
Wie prüfe ich, ob ein Befehl erfolgreich war?
Über seinen Exit-Code in $?: 0 bedeutet Erfolg, alles andere einen Fehler. Du kannst direkt if befehl; then ... schreiben oder den Code in eine Variable sichern (rc=$?) und auswerten.
Fazit
Mit diesen Grundlagen schreibst du Bash-Skripte, die nicht nur im Terminal funktionieren, sondern auch unter Cron und systemd zuverlässig laufen. Die Kernpunkte: Shebang #!/usr/bin/env bash, robuster set -euo pipefail, konsequentes Quoting mit doppelten Anführungszeichen, Iterieren über "$@", saubere Bedingungen mit [[ ... ]] und Dateitests, Funktionen mit local, Exit-Codes über $? und Command Substitution mit $(...). Für den Produktivbetrieb kommen absolute Pfade, eigenes Logging, sichere Temp-Dateien mit mktemp plus trap ... EXIT und flock gegen Überlappung hinzu. Lass jedes Skript einmal durch shellcheck laufen, dann hast du die häufigsten Fehlerklassen schon abgefangen. Die beiden Mini-Skripte – Backup-Wrapper und Healthcheck – sind eine solide Vorlage für deine eigenen Automatisierungen.
Weiterführende Anleitungen und Quellen
- Cron und Crontab: zeitgesteuerte Aufgaben unter Linux planen – so legst du deine Skripte als wiederkehrende Cron-Jobs an.
- systemd-Service erstellen und verwalten – die moderne Alternative zu Cron mit Service- und Timer-Units.
- Logs lesen mit journalctl und /var/log – die Ausgaben deiner Skripte und Timer auswerten.
- Alle Linux-Anleitungen in der Kategorie Linux – weitere Themen rund um Server und Shell.
Quellen: GNU Bash Reference Manual – Special Parameters, Use Bash Strict Mode (Unofficial Bash Strict Mode), redsymbol.net, Bash Best Practices: Writing Safer, Cleaner Scripts, Linuxize und systemd/Timers, ArchWiki.