Shellscripts schreiben — Vom ersten Skript zur Automatisierung

Fortgeschritten Produktivität Veröffentlicht am 3. April 2026 von Gunter Herdrich

Ein Bash-Script ist eine Textdatei mit Befehlen, die die Shell der Reihe nach abarbeitet — so, als ob du sie einzeln im Terminal eingeben würdest. Der Unterschied: Statt zwanzig Befehle jedes Mal neu zu tippen, rufst du einmal das Script auf. Dieser Artikel zeigt dir Schritt für Schritt, wie ein Script entsteht, wie du Variablen, Eingaben, Bedingungen und Schleifen einsetzt — und am Ende steht ein vollständiges Praxisbeispiel, das einen Ordner automatisch aufräumt.

Dein erstes Script: Datei anlegen und ausführbar machen

Jedes Bash-Script beginnt mit der sogenannten Shebang-Zeile — sie teilt dem System mit, welcher Interpreter die Datei ausführen soll. Ohne sie ist die Datei nur eine Textdatei.

Es gibt zwei gebräuchliche Varianten:

#!/bin/bash
#!/usr/bin/env bash

#!/bin/bash funktioniert auf allen Standard-Linux-Systemen, wo Bash unter /bin/bash liegt. #!/usr/bin/env bash ist portabler: env sucht Bash im aktuellen PATH, was auf macOS, Arch Linux oder Systemen mit Custom-Bash-Installationen zuverlässiger greift. Für den Desktop-Einsatz unter Ubuntu oder Mint machen beide keinen praktischen Unterschied — wähle eine und bleibe dabei.

Script anlegen, Shebang eintragen, ausführbar machen:

# Neue Datei anlegen und öffnen:
$ nano mein-script.sh

# Inhalt:
#!/bin/bash
echo "Hallo, $(whoami)!"
echo "Heute ist: $(date +%A, %d. %B %Y)"

Speichern (Strg + O), schließen (Strg + X), dann ausführbar machen:

$ chmod +x mein-script.sh

Drei Wege, das Script zu starten:

# 1. Mit ./ im aktuellen Verzeichnis (nutzt die Shebang-Zeile):
$ ./mein-script.sh
Hallo, gunter!
Heute ist: Samstag, 04. April 2026

# 2. Explizit mit bash aufrufen (Shebang wird ignoriert):
$ bash mein-script.sh
Hallo, gunter!
Heute ist: Samstag, 04. April 2026

# 3. Mit source ausführen (läuft in der aktuellen Shell, kein eigener Prozess):
$ source mein-script.sh

Der Unterschied zwischen ./script.sh und source script.sh ist entscheidend: Beim normalen Aufruf startet Bash einen Kindprozess — Variablen, die darin gesetzt werden, verschwinden danach. Bei source läuft das Script in deiner aktuellen Shell-Sitzung. Das ist nützlich, wenn das Script Variablen oder Funktionen setzen soll, die danach noch zur Verfügung stehen — zum Beispiel bei Konfigurationsdateien wie .bashrc.

Tipp: Scripts im eigenen ~/.local/bin/-Verzeichnis ablegen und dort ausführbar machen — dann kannst du sie wie normale Befehle aufrufen, ohne ./ oder den vollen Pfad anzugeben. Das Verzeichnis ist auf modernen Ubuntu-Systemen automatisch im PATH.

Variablen: Daten speichern und abrufen

Variablen speichern Werte, die du später im Script wiederverwenden willst. Das wichtigste Detail: Beim Zuweisen gibt es keine Leerzeichen um das Gleichheitszeichen — sonst interpretiert Bash den Variablennamen als Befehl.

#!/bin/bash

# Richtig:
name="Gunter"
anzahl=42
pfad="/home/gunter/dokumente"

# Falsch — führt zu Fehlermeldungen:
# name = "Gunter"   # bash: name: Befehl nicht gefunden

Wert einer Variable abrufen mit $variable oder sicherer mit "${variable}":

echo "Hallo, $name!"
echo "Anzahl: ${anzahl} Dateien"

# Doppelte Anführungszeichen verhindern Wortaufteilung bei Leerzeichen im Wert:
datei="mein dokument.txt"
echo "$datei"      # Ausgabe: mein dokument.txt — korrekt
ls $datei          # Fehler: ls versucht "mein" und "dokument.txt" separat
ls "$datei"        # Korrekt: ls bekommt den Wert als einen String

Bash kennt außerdem eine Reihe vordefinierter Sondervariablen:

#!/bin/bash
echo "Scriptname:       $0"   # Name des Scripts selbst
echo "Erstes Argument:  $1"   # Erster Wert nach dem Scriptaufruf
echo "Zweites Argument: $2"
echo "Anzahl Argumente: $#"   # Wie viele Argumente wurden übergeben?
echo "Alle Argumente:   $@"   # Alle Argumente als separate Wörter
echo "Letzter Exit:     $?"   # Exit-Status des letzten Befehls (0 = OK)
echo "Prozess-ID:       $$"   # PID dieser Shell-Sitzung
$ ./mein-script.sh alpha beta gamma
Scriptname:       ./mein-script.sh
Erstes Argument:  alpha
Zweites Argument: beta
Anzahl Argumente: 3
Alle Argumente:   alpha beta gamma
Letzter Exit:     0
Prozess-ID:       14832
Tipp: Benutze immer doppelte Anführungszeichen um Variablen: "$variable". Das verhindert unerwartetes Verhalten, wenn der Wert Leerzeichen oder Sonderzeichen enthält — einer der häufigsten Anfängerfehler in Bash-Scripts.

Benutzereingaben: read, Argumente und Passwörter

Scripts werden nützlicher, wenn sie auf Eingaben reagieren können — entweder interaktiv über read oder über Argumente beim Aufruf.

read liest eine Zeile von der Standardeingabe und speichert sie in einer Variable:

#!/bin/bash

# Einfache Eingabe:
read benutzername
echo "Du hast eingegeben: $benutzername"

# Mit Prompt-Text (-p) in einer Zeile:
read -p "Wie heißt du? " name
echo "Willkommen, $name!"

# Passwort eingeben (keine Anzeige, -s = silent):
read -s -p "Passwort: " passwort
echo ""   # Zeilenumbruch nach der versteckten Eingabe
echo "Passwort hat ${#passwort} Zeichen."   # Länge, nicht Inhalt!
$ ./eingabe.sh
Wie heißt du? Gunter
Willkommen, Gunter!
Passwort:
Passwort hat 8 Zeichen.

Für Scripts, die ohne Interaktion auskommen sollen — zum Beispiel in Cron-Jobs — werden Werte als Argumente übergeben:

#!/bin/bash
# Aufruf: ./backup.sh /home/gunter /mnt/backup

quelle="$1"
ziel="$2"

if [ -z "$quelle" ] || [ -z "$ziel" ]; then
    echo "Verwendung: $0  "
    exit 1
fi

echo "Sichere $quelle nach $ziel ..."
$ ./backup.sh /home/gunter /mnt/backup
Sichere /home/gunter nach /mnt/backup ...

Bedingte Ausführung mit if

Mit if entscheidet das Script, welcher Codeblock ausgeführt wird — abhängig davon, ob eine Bedingung wahr oder falsch ist. Die Grundstruktur:

if BEDINGUNG; then
    # wird ausgeführt, wenn Bedingung wahr
elif ANDERE_BEDINGUNG; then
    # alternative Bedingung
else
    # wenn keine Bedingung zutrifft
fi

Bedingungen werden mit [ ] (POSIX-kompatibel) oder [[ ]] (Bash-spezifisch, leistungsfähiger) formuliert. Für Bash-Scripts empfehle ich [[ ]] — es kennt reguläre Ausdrücke, verarbeitet leere Variablen robuster und braucht keine Anführungszeichen um Variablen mit Leerzeichen.

Dateibedingungen:

#!/bin/bash
datei="/home/gunter/notizen.txt"

if [[ -f "$datei" ]]; then
    echo "Datei existiert."
elif [[ -d "$datei" ]]; then
    echo "Das ist ein Verzeichnis, keine Datei."
else
    echo "Existiert nicht."
fi

# Weitere Test-Ausdrücke für Dateien:
# -e  Existiert (Datei oder Verzeichnis)
# -f  Ist eine reguläre Datei
# -d  Ist ein Verzeichnis
# -r  Ist lesbar
# -w  Ist schreibbar
# -x  Ist ausführbar
# -s  Ist nicht leer (size > 0)

String-Bedingungen:

#!/bin/bash
read -p "Eingabe: " wert

if [[ "$wert" = "ja" ]]; then
    echo "Bestätigt."
elif [[ "$wert" != "nein" ]]; then
    echo "Weder ja noch nein."
fi

# -z  String ist leer (zero length)
# -n  String ist nicht leer (non-zero)
if [[ -z "$wert" ]]; then
    echo "Nichts eingegeben."
fi

Zahlen-Bedingungen:

#!/bin/bash
anzahl=15

if [[ $anzahl -eq 0 ]]; then
    echo "Keine Dateien."
elif [[ $anzahl -gt 100 ]]; then
    echo "Mehr als 100 Dateien."
else
    echo "$anzahl Dateien gefunden."
fi

# -eq  gleich (equal)
# -ne  ungleich (not equal)
# -lt  kleiner als (less than)
# -gt  größer als (greater than)
# -le  kleiner oder gleich
# -ge  größer oder gleich
Achtung: Zahlenvergleiche funktionieren nur mit ganzen Zahlen. Für Dezimalzahlen musst du auf externe Tools wie bc oder awk zurückgreifen — Bash selbst rechnet nur mit Integern.

Schleifen: for, while, until

Schleifen wiederholen einen Codeblock — für jedes Element einer Liste, solange eine Bedingung gilt, oder eine bestimmte Anzahl von Malen.

for-Schleife über eine Liste:

#!/bin/bash

# Über eine feste Liste:
for farbe in rot grün blau; do
    echo "Farbe: $farbe"
done

# Ausgabe:
# Farbe: rot
# Farbe: grün
# Farbe: blau

for-Schleife mit Zahlbereich:

#!/bin/bash

# Brace Expansion {1..10}:
for i in {1..5}; do
    echo "Durchlauf $i"
done

# C-Stil (bash-spezifisch):
for ((i=1; i<=5; i++)); do
    echo "Schritt $i von 5"
done

for-Schleife über Dateien:

#!/bin/bash

# Alle .txt-Dateien im aktuellen Verzeichnis:
for datei in *.txt; do
    echo "Gefunden: $datei ($(wc -l < "$datei") Zeilen)"
done

while-Schleife:

#!/bin/bash

# Solange Bedingung wahr:
zaehler=1
while [[ $zaehler -le 5 ]]; do
    echo "Zaehler: $zaehler"
    ((zaehler++))
done

# while read: Datei Zeile für Zeile verarbeiten:
while IFS= read -r zeile; do
    echo "Zeile: $zeile"
done < eingabe.txt

until-Schleife — Gegenstück zu while, läuft solange die Bedingung falsch ist:

#!/bin/bash
versuch=0
until [[ $versuch -ge 3 ]]; do
    echo "Versuch $((versuch+1))"
    ((versuch++))
done

break bricht die Schleife sofort ab, continue überspringt den Rest des aktuellen Durchlaufs und springt zum nächsten:

#!/bin/bash
for i in {1..10}; do
    if [[ $i -eq 4 ]]; then
        continue   # 4 überspringen
    fi
    if [[ $i -eq 8 ]]; then
        break      # bei 8 aufhören
    fi
    echo "$i"
done
# Ausgabe: 1 2 3 5 6 7

Case-Anweisung: Elegante Mehrfachauswahl

Wenn ein Wert viele mögliche Ausprägungen hat, wird eine Kette von elif-Blöcken schnell unübersichtlich. case ist hier lesbarer und effizienter — Bash übersetzt es intern direkt, ohne jede Bedingung einzeln zu prüfen.

#!/bin/bash
# Einfaches Menü-Script

echo "Was möchtest du tun?"
echo "  1) Festplatte prüfen"
echo "  2) Speicher anzeigen"
echo "  3) Uptime ausgeben"
echo "  q) Beenden"
echo ""
read -p "Auswahl: " wahl

case "$wahl" in
    1)
        df -h
        ;;
    2)
        free -h
        ;;
    3)
        uptime
        ;;
    q|Q)
        echo "Tschüss!"
        exit 0
        ;;
    *)
        echo "Ungültige Eingabe: $wahl"
        exit 1
        ;;
esac
$ ./menue.sh
Was möchtest du tun?
  1) Festplatte prüfen
  2) Speicher anzeigen
  3) Uptime ausgeben
  q) Beenden

Auswahl: 2
              total        used        free      shared  buff/cache   available
Mem:          15Gi        4,2Gi       8,1Gi       312Mi       3,1Gi       10Gi
Swap:          2,0Gi          0B       2,0Gi

Das Pattern q|Q zeigt, dass case mehrere Muster mit | verbinden kann. Der Abschluss *) fängt alles ab, was kein anderes Muster trifft — vergleichbar mit else bei if.

Tipp: Das doppelte Semikolon ;; nach jedem Block ist Pflicht — es beendet den Zweig. Vergisst du es, gibt Bash eine Fehlermeldung. In neueren Bash-Versionen (ab 4.0) gibt es auch ;& (Fallthrough) und ;;& (weiter testen), aber für Menüscripts reicht ;; immer.

Funktionen: Code wiederverwenden

Funktionen bündeln zusammengehörigen Code unter einem Namen. Sie vermeiden Wiederholungen und machen Scripts lesbarer und testbarer. Definiert werden sie vor dem ersten Aufruf — entweder mit function name {} oder einfacher als name() {}:

#!/bin/bash

# Definition:
begruessung() {
    local name="$1"    # lokale Variable, nur innerhalb der Funktion sichtbar
    echo "Hallo, $name!"
}

# Aufruf — wie ein Befehl, Argumente wie beim Script:
begruessung "Gunter"
begruessung "Welt"
Hallo, Gunter!
Hallo, Welt!

Wichtig: local für Variablen innerhalb von Funktionen. Ohne local sind Variablen global — sie überschreiben gleichnamige Variablen außerhalb der Funktion. Das führt zu schwer findbaren Fehlern:

#!/bin/bash
ergebnis="leer"

berechne() {
    local ergebnis="42"   # betrifft nur diese Funktion
    echo "In Funktion: $ergebnis"
}

berechne
echo "Außen: $ergebnis"   # gibt "leer" aus, nicht "42"

Rückgabewerte: return setzt nur den Exit-Status (0–255, wie bei Befehlen), keinen String. Um einen Wert zurückzugeben, nutze echo und fange die Ausgabe mit Befehlssubstitution auf:

#!/bin/bash

# Wert per echo zurückgeben:
addiere() {
    local summe=$(( $1 + $2 ))
    echo "$summe"
}

ergebnis=$(addiere 17 25)
echo "17 + 25 = $ergebnis"

# Exit-Status per return:
ist_gerade() {
    if (( $1 % 2 == 0 )); then
        return 0   # 0 = wahr (Erfolg)
    else
        return 1   # 1 = falsch (Fehler)
    fi
}

if ist_gerade 8; then
    echo "8 ist gerade."
fi

Exit-Status und Fehlerbehandlung

Jeder Befehl in Bash gibt nach seiner Ausführung einen Exit-Status zurück. Null (0) bedeutet Erfolg, alles andere signalisiert einen Fehler. Den letzten Exit-Status findest du in $?:

$ ls /home/gunter
Dokumente  Downloads  Musik
$ echo $?
0

$ ls /nicht-vorhanden
ls: Zugriff auf '/nicht-vorhanden' nicht möglich: Kein solches Verzeichnis
$ echo $?
2

Mit && und || kannst du Befehle abhängig vom Exit-Status verketten:

# Zweiter Befehl nur wenn erster erfolgreich (Exit 0):
mkdir /tmp/backup && echo "Verzeichnis angelegt."

# Zweiter Befehl nur wenn erster fehlschlägt (Exit != 0):
mkdir /tmp/backup || echo "Konnte Verzeichnis nicht anlegen!"

# Kombiniert — typisches Muster:
cp datei.txt /backup/ && echo "OK" || echo "Fehler beim Kopieren"

Für robustere Scripts gibt es drei wichtige Shell-Optionen:

#!/bin/bash
set -e    # Script sofort beenden, wenn ein Befehl fehlschlägt
set -u    # Fehler bei Verwendung undefinierter Variablen
set -o pipefail   # Fehler in Pipes nicht verschlucken

# Oft zusammen als:
set -euo pipefail

Mit trap kannst du bestimmte Aktionen ausführen, wenn das Script beendet wird — zum Beispiel zum Aufräumen temporärer Dateien:

#!/bin/bash
set -euo pipefail

tmp_datei=$(mktemp)

# Beim Beenden immer aufräumen (auch bei Fehler oder Strg+C):
trap "rm -f '$tmp_datei'" EXIT

echo "Temporäre Datei: $tmp_datei"
echo "Daten..." > "$tmp_datei"
# Hier passiert die eigentliche Arbeit
cat "$tmp_datei"
# Am Ende: trap löscht $tmp_datei automatisch
Achtung: set -e bricht das Script ab, sobald ein Befehl einen Exit-Status ungleich 0 zurückgibt — auch in if-Bedingungen, was manchmal unerwartet ist. Wenn ein Befehl absichtlich fehlschlagen darf, hänge || true an: befehl || true. Damit bekommt die Zeile immer Exit-Status 0.

Praxisbeispiel: Ordner automatisch aufräumen

Das folgende Script verschiebt alle Dateien, die älter als eine bestimmte Anzahl von Tagen sind, in ein Archiv-Unterverzeichnis und schreibt dabei ein Log. Es zeigt, wie die Konzepte aus diesem Artikel zusammenspielen.

#!/bin/bash
set -euo pipefail

# ---- Konfiguration ----------------------------------------
QUELL_ORDNER="${1:-/home/gunter/Downloads}"   # Standard falls kein Argument
ARCHIV_ORDNER="$QUELL_ORDNER/archiv"
MAX_ALTER_TAGE="${2:-30}"                      # Dateien älter als X Tage
LOG_DATEI="$QUELL_ORDNER/aufraeum.log"
# -----------------------------------------------------------

log() {
    local meldung="$1"
    local zeitstempel
    zeitstempel=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$zeitstempel] $meldung" | tee -a "$LOG_DATEI"
}

pruefe_voraussetzungen() {
    if [[ ! -d "$QUELL_ORDNER" ]]; then
        echo "Fehler: Verzeichnis nicht gefunden: $QUELL_ORDNER" >&2
        exit 1
    fi
    mkdir -p "$ARCHIV_ORDNER"
}

raeume_auf() {
    local verschoben=0
    local uebersprungen=0

    log "=== Aufräumen gestartet (Quelle: $QUELL_ORDNER, Alter: ${MAX_ALTER_TAGE}d) ==="

    while IFS= read -r -d '' datei; do
        dateiname=$(basename "$datei")

        # Archiv selbst und Log-Datei überspringen:
        if [[ "$datei" == "$ARCHIV_ORDNER"* ]] || [[ "$datei" == "$LOG_DATEI" ]]; then
            ((uebersprungen++))
            continue
        fi

        mv "$datei" "$ARCHIV_ORDNER/$dateiname"
        log "Verschoben: $dateiname"
        ((verschoben++))

    done < <(find "$QUELL_ORDNER" -maxdepth 1 -type f -mtime +"$MAX_ALTER_TAGE" -print0)

    log "=== Fertig: $verschoben Datei(en) verschoben, $uebersprungen übersprungen ==="
}

# ---- Hauptprogramm ----------------------------------------
pruefe_voraussetzungen
raeume_auf

Aufruf mit Standardwerten (Downloads, 30 Tage) oder eigenen Werten:

# Standard: Downloads-Ordner, Dateien älter als 30 Tage:
$ ./aufraeumen.sh

# Eigene Parameter: Dokumente-Ordner, Dateien älter als 60 Tage:
$ ./aufraeumen.sh /home/gunter/Dokumente 60

# Log anschauen:
$ tail -20 /home/gunter/Downloads/aufraeum.log
[2026-04-04 10:45:00] === Aufräumen gestartet (Quelle: /home/gunter/Downloads, Alter: 30d) ===
[2026-04-04 10:45:01] Verschoben: ubuntu-24.04.iso
[2026-04-04 10:45:01] Verschoben: rechnung-februar.pdf
[2026-04-04 10:45:01] === Fertig: 2 Datei(en) verschoben, 0 übersprungen ===

Das Script nutzt: Funktionen (log, pruefe_voraussetzungen, raeume_auf), Standardwerte für Argumente (${1:-/pfad}), find mit -print0 und while read -d '' für sichere Dateinamen mit Leerzeichen, Fehlerbehandlung per set -euo pipefail, Logging mit Zeitstempel und tee. Das ist solider Produktionscode, kein Lern-Spielzeug.

Tipp: Scripts regelmäßig automatisch ausführen lassen? Dafür ist Cron zuständig: crontab -e öffnet den Cron-Editor. Ein Eintrag wie 0 2 * * 0 /home/gunter/.local/bin/aufraeumen.sh startet das Script jeden Sonntag um 2 Uhr nachts — ohne dein Zutun.

Weiterlernen

Du hast jetzt alle grundlegenden Bausteine für eigene Bash-Scripts: Variablen, Eingaben, Bedingungen, Schleifen, Funktionen, Fehlerbehandlung. Das reicht für die meisten Automatisierungsaufgaben im Alltag.

Drei Themen, die direkt an diesen Artikel anknüpfen:

  • Komfortfunktionen wie Redirect, Pipes und Wildcards helfen dir, Scripts kompakter und mächtiger zu schreiben: Komfortfunktionen in der Bash
  • Die Befehle, die du in deinen Scripts brauchst — find, grep, cut, sort — sind auf der Kommando-Referenz zusammengefasst: Grundlegende Kommandos
  • Welchen Editor du für deine Scripts nutzt, ist Geschmackssache — eine Übersicht der gängigen Terminal-Editoren findest du hier: Editoren in der Shell