Material zum Kurs
Was Sie mitnehmen: die Skripte und Vorlagen, mit denen gearbeitet wird, die Prüfliste des ganzen Kurses und alle Befehle auf einem Blatt. Alles hier wird aus dem Kurs erzeugt — es ist also immer der Stand, den Sie gerade lernen.
Die Abnahme
Die Prüfliste des ganzen Kurses
Alle Prüfpunkte am Stück. Mit Zugang haken Sie hier ab — der Stand ist derselbe wie in den Phasen.
Station 01 · EinrichtenBevor es weitergeht
Station 01 · EinrichtenPrüfliste Absicherung
Station 01 · EinrichtenPrüfliste Sicherung
Station 01 · EinrichtenPrüfliste Inbetriebnahme
Nachschlagen
Alle Befehle des Kurses
In der Reihenfolge, in der sie im Kurs vorkommen.
Auslöser: Eine Anfrage über das Kontaktformular geht ein.
Schritte: Pflichtfelder prüfen · Vorgangsschlüssel bilden · in der Datenbank ablegen ·
Vertrieb benachrichtigen · dem Absender bestätigen.
Ergebnis: Abgelegt und beide informiert — oder in der Liste "offene Fälle",
mit dem Grund dabei.# Steckbrief: <Name des Ablaufs> > Eine Seite, ausgefüllt **bevor** gebaut wird. Wer sie nicht ausfüllen kann, > hat noch keinen Ablauf, sondern einen Wunsch. Ausgefüllt gehört sie neben den > Ablauf ins Repository — sie ist die Beschreibung, die der Nachfolger liest. | | | |---|---| | **Name** | z. B. `anfrage-kontaktformular` | | **Zuständige Person** | Name, nicht Abteilung | | **Vertretung** | wer im Urlaub einspringt | | **Angelegt am / von** | | | **Fassung** | 1 | ## 1 · Auslöser — genau einer - [ ] **Ereignis:** _welches, aus welchem System_ - [ ] **Uhrzeit:** _wann genau, an welchen Tagen_ - [ ] **Zuruf:** _wer drückt den Knopf_ **Wie oft kommt er vor?** ca. ____ mal je Woche ## 2 · Schritte — in der richtigen Reihenfolge | Nr | Was geschieht | Umkehrbar? | Fremdsystem | |----|---------------|------------|-------------| | 1 | | ja / nein | | | 2 | | ja / nein | | | 3 | | ja / nein | | | 4 | | ja / nein | | > **Regel:** alles Umkehrbare zuerst, alles Endgültige (Mail, Zahlung, > Fremdsystem) zuletzt. Dann ist jeder Abbruch folgenlos. ## 3 · Ergebnis — beide Ausgänge - **Durchgelaufen heißt:** _woran sieht man das, wo steht es_ - **Vorgelegt heißt:** _wohin geht der Zweifelsfall, mit welchem Grund_ - **Wer bearbeitet die vorgelegten Fälle?** ## 4 · Der Vorgangsschlüssel - **Woraus besteht er?** _z. B. Bestellnummer, oder E-Mail + gerundeter Zeitpunkt_ - [ ] Er kommt aus dem Vorgang selbst — **keine Zufallszahl** - [ ] Ein zweiter Durchlauf mit demselben Vorgang legt nichts doppelt an ## 5 · Wenn es schiefgeht - **Fehler-Ablauf eingetragen?** ja / nein - **Meldung geht an:** _Person_ - **Was geschieht bis zur Behebung von Hand?** _der Weg, den es vorher gab_ ## 6 · Stillstand - **Frist für das Lebenszeichen:** ____ (minütlich → 15 min · stündlich → 3 h · täglich → bis 9:00) - **Wer bekommt die Stillstandsmeldung?** ## 7 · Daten - **Welche personenbezogenen Daten laufen durch?** - **Welche Fremdsysteme sehen sie?** _und liegt dafür ein Vertrag vor?_ - **Wie lange bleiben die Daten in den Ausführungen von n8n stehen?** ____ Tage
scp -r automatisierung betreiber@apps.example.de:~
sudo bash automatisierung/n8n-aufsetzen.sh \ --domain ablauf.example.de --email ich@example.de --nur-pruefen
sudo bash automatisierung/n8n-aufsetzen.sh \ --domain ablauf.example.de --email ich@example.de
#!/usr/bin/env bash
#
# n8n-aufsetzen.sh — aus einem Server mit Docker wird eine Umgebung,
# in der Abläufe laufen können.
#
# Vorher: ein Server nach bootstrap.sh (Docker, Traefik über Coolify, Domain)
# Nachher: n8n und seine Datenbank als Container, hinter HTTPS, mit
# gesichertem Verschlüsselungsschlüssel und aufgeräumten Läufen
#
# Das Skript darf mehrfach laufen. Jeder Schritt prüft erst, ob er schon
# erledigt ist. Ein zweiter Lauf ändert nichts und ergänzt, was fehlt.
#
# bash n8n-aufsetzen.sh --domain ablauf.example.de --email ich@example.de --nur-pruefen
# bash n8n-aufsetzen.sh --domain ablauf.example.de --email ich@example.de
#
# Kurs 3 der DevEnv-Lernstrecke, Station 01. Was hier passiert, wird gezeigt
# und benannt — auswendig lernen muss es niemand.
set -Eeuo pipefail
# =====================================================================
# Einstellungen
# =====================================================================
DOMAIN=""
EMAIL=""
AUSFUEHREN=1 # 0 = Trockenlauf: nur zeigen, was geschähe
VERZEICHNIS="/opt/n8n"
NETZ="coolify" # das Docker-Netz, in dem Traefik lauscht
ZEITZONE="Europe/Berlin"
LAUFE_TAGE=14 # so lange werden Ausführungsdaten aufbewahrt
BILD="docker.n8n.io/n8nio/n8n:1.114.0"
DB_BILD="postgres:16-alpine"
UMSCHLAG="/root/n8n-notfallumschlag.tar.gz"
# =====================================================================
# Ausgabe — vier Zeilen je Problem: Ursache, Folge, nächster Schritt, Fundstelle
# =====================================================================
if [[ -t 1 ]]; then
F_TITEL=$'\e[1m'; F_OK=$'\e[32m'; F_WARN=$'\e[33m'; F_FEHLER=$'\e[31m'; F_LEISE=$'\e[2m'; F_AUS=$'\e[0m'
else
F_TITEL=""; F_OK=""; F_WARN=""; F_FEHLER=""; F_LEISE=""; F_AUS=""
fi
schritt() { printf '\n%s▸ %s%s\n' "$F_TITEL" "$1" "$F_AUS"; }
ok() { printf ' %s✓%s %s\n' "$F_OK" "$F_AUS" "$1"; }
info() { printf ' %s·%s %s\n' "$F_LEISE" "$F_AUS" "$1"; }
warnung() { printf ' %s!%s %s\n' "$F_WARN" "$F_AUS" "$1"; }
tue() { if (( AUSFUEHREN )); then eval "$1"; else printf ' %s… würde tun:%s %s\n' "$F_LEISE" "$F_AUS" "$1"; fi; }
abbruch() {
printf '\n%s✗ Abgebrochen%s\n' "$F_FEHLER" "$F_AUS" >&2
printf ' Ursache: %s\n' "$1" >&2
printf ' Folge: %s\n' "$2" >&2
printf ' Nächster Schritt: %s\n' "$3" >&2
printf ' Fundstelle: %s\n\n' "${4:-Kurs 3, Station 01}" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="${2:?--domain braucht einen Wert}"; shift 2 ;;
--email) EMAIL="${2:?--email braucht einen Wert}"; shift 2 ;;
--verzeichnis) VERZEICHNIS="${2:?}"; shift 2 ;;
--netz) NETZ="${2:?}"; shift 2 ;;
--nur-pruefen) AUSFUEHREN=0; shift ;;
-h|--hilfe|--help)
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) abbruch "Unbekannte Angabe: $1" "Nichts wurde geändert." "bash $0 --hilfe" ;;
esac
done
[[ -n "$DOMAIN" ]] || abbruch "Keine Domain angegeben." \
"Ohne Domain gibt es kein Zertifikat und keine Webhook-Adresse." \
"bash $0 --domain ablauf.example.de --email ich@example.de"
# =====================================================================
# 1 · Vorabprüfung — was da sein muss, bevor irgendetwas entsteht
# =====================================================================
schritt "1 · Vorabprüfung"
if (( EUID != 0 )); then
if (( AUSFUEHREN )); then
abbruch "Nicht als root gestartet." "Verzeichnisse unter /opt lassen sich nicht anlegen." "Mit sudo ausführen: sudo bash $0 …"
else
warnung "Nicht als root — für den Trockenlauf genügt das, für den scharfen Lauf nicht"
fi
else
ok "Als root gestartet"
fi
command -v docker >/dev/null || abbruch "Docker fehlt." \
"n8n läuft als Container — ohne Docker geht nichts." \
"Erst bootstrap.sh aus Kurs 1 laufen lassen." "Kurs 1, Station 03"
ok "Docker vorhanden ($(docker --version | cut -d' ' -f3 | tr -d ,))"
if docker network inspect "$NETZ" >/dev/null 2>&1; then
ok "Docker-Netz „${NETZ}“ vorhanden"
else
warnung "Docker-Netz „${NETZ}“ fehlt — ohne Traefik im selben Netz gibt es kein Zertifikat"
info "Anderes Netz: --netz <name>. Ohne Coolify: Traefik selbst betreiben (siehe README)."
fi
EIGENE_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || echo '')"
aufloesen() {
if command -v dig >/dev/null; then dig +short A "$1" 2>/dev/null | tail -1
elif command -v getent >/dev/null; then getent hosts "$1" 2>/dev/null | awk '{print $1}' | tail -1
elif command -v host >/dev/null; then host "$1" 2>/dev/null | awk '/has address/{print $NF}' | tail -1
fi
}
ZIEL="$(aufloesen "$DOMAIN" || true)"
if [[ -z "$ZIEL" ]]; then
warnung "${DOMAIN} zeigt auf nichts — A-Eintrag setzen, sonst kommt kein Zertifikat"
elif [[ -n "$EIGENE_IP" && "$ZIEL" != "$EIGENE_IP" ]]; then
warnung "${DOMAIN} zeigt auf ${ZIEL}, dieser Server ist ${EIGENE_IP}"
else
ok "${DOMAIN} zeigt hierher"
fi
FREI_MB="$(df -Pm / | awk 'NR==2{print $4}')"
(( FREI_MB > 2048 )) || abbruch "Weniger als 2 GB frei (${FREI_MB} MB)." \
"Der Abbild-Download bricht mittendrin ab." "Platz schaffen: docker system prune -a"
ok "Platz frei: ${FREI_MB} MB"
# =====================================================================
# 2 · Verzeichnis und Geheimnisse
# =====================================================================
schritt "2 · Verzeichnis und Geheimnisse"
tue "mkdir -p '${VERZEICHNIS}/daten' '${VERZEICHNIS}/db' '${VERZEICHNIS}/ablaeufe'"
tue "chmod 700 '${VERZEICHNIS}'"
UMGEBUNG="${VERZEICHNIS}/.env"
if [[ -f "$UMGEBUNG" ]]; then
ok "Umgebungsdatei besteht schon — Geheimnisse bleiben unverändert"
info "Das ist Absicht: Ein neuer Verschlüsselungsschlüssel macht alle hinterlegten Zugänge unlesbar."
else
SCHLUESSEL="$(openssl rand -hex 32)"
DB_KENNWORT="$(openssl rand -base64 30 | tr -d '/+=' | cut -c1-28)"
WEBHOOK_TOKEN="$(openssl rand -hex 24)"
if (( AUSFUEHREN )); then
umask 077
cat > "$UMGEBUNG" <<UMG
# n8n — Umgebung. Diese Datei ist das Geheimnis dieser Installation.
# Der Verschlüsselungsschlüssel entschlüsselt ALLE hinterlegten Zugänge.
# Ohne ihn ist jede Sicherung wertlos. Er gehört zusätzlich in den
# Passwortspeicher des Unternehmens — nicht nur hierher.
N8N_ENCRYPTION_KEY=${SCHLUESSEL}
# Wo n8n erreichbar ist. WEBHOOK_URL muss stimmen, sonst tragen die
# Auslöser-Knoten eine falsche Adresse ein.
DOMAIN=${DOMAIN}
N8N_HOST=${DOMAIN}
N8N_PORT=5678
N8N_PROTOCOL=https
WEBHOOK_URL=https://${DOMAIN}/
N8N_EDITOR_BASE_URL=https://${DOMAIN}/
# Datenbank
POSTGRES_DB=n8n
POSTGRES_USER=n8n
POSTGRES_PASSWORD=${DB_KENNWORT}
# Vorschlag für das Geheimnis des Beispiel-Auslösers. Es wird NICHT von n8n
# gelesen: Sie tragen den Wert beim Anlegen des Zugangs "Anfrage-Token"
# (Header Auth) in der Oberflaeche ein, und das Formular schickt ihn im Kopf
# "x-anfrage-token" mit. Hier steht er nur, damit er einmal erzeugt ist.
ANFRAGE_TOKEN=${WEBHOOK_TOKEN}
# Zeitzone und Aufräumen der Läufe
GENERIC_TIMEZONE=${ZEITZONE}
TZ=${ZEITZONE}
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=$(( LAUFE_TAGE * 24 ))
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
# Nichts nach draußen erzählen, nichts aus der Umgebung preisgeben
N8N_DIAGNOSTICS_ENABLED=false
N8N_BLOCK_ENV_ACCESS_IN_NODE=true
N8N_PAYLOAD_SIZE_MAX=16
UMG
chmod 600 "$UMGEBUNG"
ok "Umgebungsdatei angelegt (${UMGEBUNG}, nur für root lesbar)"
warnung "Den Wert von N8N_ENCRYPTION_KEY JETZT in den Passwortspeicher übertragen."
else
info "… würde ${UMGEBUNG} anlegen, mit neuem Verschlüsselungsschlüssel und Datenbankkennwort"
fi
fi
# =====================================================================
# 3 · docker-compose.yml
# =====================================================================
schritt "3 · Beschreibung der Container"
COMPOSE="${VERZEICHNIS}/docker-compose.yml"
if (( AUSFUEHREN )); then
cat > "$COMPOSE" <<'COMP'
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
networks: [intern]
n8n:
image: docker.n8n.io/n8nio/n8n:1.114.0
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: ${N8N_HOST}
N8N_PORT: 5678
N8N_PROTOCOL: ${N8N_PROTOCOL}
WEBHOOK_URL: ${WEBHOOK_URL}
N8N_EDITOR_BASE_URL: ${N8N_EDITOR_BASE_URL}
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: db
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
TZ: ${TZ}
EXECUTIONS_DATA_PRUNE: ${EXECUTIONS_DATA_PRUNE}
EXECUTIONS_DATA_MAX_AGE: ${EXECUTIONS_DATA_MAX_AGE}
EXECUTIONS_DATA_PRUNE_MAX_COUNT: ${EXECUTIONS_DATA_PRUNE_MAX_COUNT}
N8N_DIAGNOSTICS_ENABLED: ${N8N_DIAGNOSTICS_ENABLED}
N8N_BLOCK_ENV_ACCESS_IN_NODE: ${N8N_BLOCK_ENV_ACCESS_IN_NODE}
N8N_PAYLOAD_SIZE_MAX: ${N8N_PAYLOAD_SIZE_MAX}
volumes:
- ./daten:/home/node/.n8n
- ./ablaeufe:/ablaeufe
labels:
# Traefik veröffentlicht n8n unter der Domain und holt das Zertifikat.
# Nach außen ist nur 443 offen — 5678 wird nirgends freigegeben.
traefik.enable: "true"
traefik.docker.network: coolify
traefik.http.routers.n8n.rule: Host(`${DOMAIN}`)
traefik.http.routers.n8n.entrypoints: https
traefik.http.routers.n8n.tls: "true"
traefik.http.routers.n8n.tls.certresolver: letsencrypt
traefik.http.services.n8n.loadbalancer.server.port: "5678"
networks: [intern, coolify]
networks:
intern:
internal: true
coolify:
external: true
COMP
ok "docker-compose.yml geschrieben (${COMPOSE})"
info "Die Datenbank hängt nur im internen Netz — sie ist von außen nicht erreichbar."
else
info "… würde ${COMPOSE} schreiben (n8n + PostgreSQL, Traefik-Labels, internes Netz)"
fi
# =====================================================================
# 4 · Starten
# =====================================================================
schritt "4 · Starten"
tue "cd '${VERZEICHNIS}' && docker compose pull"
tue "cd '${VERZEICHNIS}' && docker compose up -d"
if (( AUSFUEHREN )); then
for _ in $(seq 1 30); do
if docker compose -f "$COMPOSE" ps --format '{{.Service}} {{.State}}' 2>/dev/null | grep -q 'n8n running'; then break; fi
sleep 2
done
docker compose -f "$COMPOSE" ps || true
ok "Container gestartet"
fi
# =====================================================================
# 5 · Notfallumschlag
# =====================================================================
schritt "5 · Notfallumschlag"
if (( AUSFUEHREN )); then
if [[ -x "$(dirname "$0")/n8n-sichern.sh" ]] || [[ -f "$(dirname "$0")/n8n-sichern.sh" ]]; then
bash "$(dirname "$0")/n8n-sichern.sh" --verzeichnis "$VERZEICHNIS" --ziel "$UMSCHLAG" || warnung "Sicherung fehlgeschlagen — von Hand nachholen"
else
warnung "n8n-sichern.sh liegt nicht daneben — Sicherung von Hand einrichten"
fi
else
info "… würde n8n-sichern.sh laufen lassen und ${UMSCHLAG} anlegen"
fi
# =====================================================================
# Ergebnis
# =====================================================================
printf '\n%s✓ Fertig%s\n' "$F_OK" "$F_AUS"
cat <<ENDTEXT
Nächste Schritte
1. https://${DOMAIN} aufrufen und das erste Konto anlegen — wer zuerst kommt,
wird Eigentümer. Das sollten Sie sein, nicht der Zufall.
2. Zweiten Faktor einschalten (Einstellungen → eigenes Konto).
3. N8N_ENCRYPTION_KEY aus ${UMGEBUNG} in den Passwortspeicher übertragen.
4. Den Umschlag vom Server holen:
scp betreiber@${DOMAIN}:${UMSCHLAG} ~/Desktop/
5. Prüfen: sudo bash n8n-check.sh --domain ${DOMAIN}
ENDTEXTN8N_ENCRYPTION_KEY=<openssl rand -hex 32> DOMAIN=ablauf.example.de N8N_HOST=ablauf.example.de N8N_PROTOCOL=https WEBHOOK_URL=https://ablauf.example.de/ N8N_EDITOR_BASE_URL=https://ablauf.example.de/ POSTGRES_DB=n8n POSTGRES_USER=n8n POSTGRES_PASSWORD=<langes Kennwort> GENERIC_TIMEZONE=Europe/Berlin TZ=Europe/Berlin EXECUTIONS_DATA_PRUNE=true EXECUTIONS_DATA_MAX_AGE=336 EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000 N8N_DIAGNOSTICS_ENABLED=false N8N_BLOCK_ENV_ACCESS_IN_NODE=true N8N_PAYLOAD_SIZE_MAX=16
sudo ufw status docker compose -f /opt/n8n/docker-compose.yml ps
EXECUTIONS_DATA_PRUNE=true EXECUTIONS_DATA_MAX_AGE=336 # Stunden — hier 14 Tage EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
# Checkliste: n8n sicher betreiben > Reihenfolge ist Absicht: Was oben steht, schließt die Tür, die am > wahrscheinlichsten offen steht. Geprüft wird mit `n8n-check.sh`, wo möglich. ## Zugang zur Oberfläche - [ ] Ein Konto je Person — kein geteiltes Anmelden - [ ] Zweiter Faktor eingeschaltet, mindestens für den Eigentümer - [ ] Ausgeschiedene Personen sind gesperrt (nicht nur „nicht mehr benutzt") - [ ] Neue Konten legt nur an, wer schon drin ist — keine offene Registrierung ## Netz - [ ] Nur 443 nach außen offen; Port 5678 ist nirgends veröffentlicht - [ ] Die Datenbank hängt im internen Netz und ist von außen nicht erreichbar - [ ] HTTPS mit gültigem Zertifikat, keine Ausnahme für „schnell mal" ## Der Auslöser von außen (Webhook) - [ ] Kennwort oder Signatur wird geprüft — eine geheime Adresse ist **keine** Absicherung - [ ] Es werden nur die erwarteten Felder übernommen, alles andere verworfen - [ ] Die Größe der Anfrage ist begrenzt - [ ] Die Antwort verrät nichts über das Innere ## Geheimnisse - [ ] `N8N_ENCRYPTION_KEY` ist gesetzt und liegt **zusätzlich** im Passwortspeicher - [ ] Die Umgebungsdatei ist nur für root lesbar (`chmod 600`) - [ ] Kein Geheimnis steht in einem Knoten, einer Notiz oder einem Ausdruck - [ ] Jeder Zugang hat nur die Rechte, die er braucht (eigener Datenbankbenutzer, eigenes Postfach) ## Sicherung - [ ] Tägliche Sicherung von Datenbank **und** Schlüssel (`n8n-sichern.sh`) - [ ] Die Sicherung liegt außerhalb des Servers - [ ] Einmal im Quartal auf einen leeren Container zurückgespielt — mit Probelauf - [ ] Die Abläufe liegen als JSON im Repository, mit Historie ## Daten - [ ] Die Ausführungen werden aufgeräumt (`EXECUTIONS_DATA_PRUNE`), mit einer begründeten Frist - [ ] Es ist bekannt, welche personenbezogenen Daten in den Ausführungen stehen - [ ] Fremdsysteme, die Daten sehen, sind im Verarbeitungsverzeichnis erfasst ## Pflege - [ ] Die n8n-Fassung ist festgenagelt (kein `latest`) - [ ] Vor jeder Aktualisierung: Sicherung, danach: ein Probelauf - [ ] Die Änderungsliste der neuen Fassung wurde gelesen
sudo bash automatisierung/n8n-sichern.sh # zusätzlich verschlüsselt: sudo bash automatisierung/n8n-sichern.sh --passwort
#!/usr/bin/env bash
#
# n8n-sichern.sh — Datenbank, Schlüssel und Abläufe in eine Datei.
#
# n8n verschlüsselt alle hinterlegten Zugänge mit einem einzigen Schlüssel:
# N8N_ENCRYPTION_KEY. Wer die Datenbank sichert, diesen Schlüssel aber nicht,
# hat eine Sicherung, die sich nicht mehr öffnen lässt — die Daten sind
# vorhanden und unlesbar. Deshalb legt dieses Skript beides zusammen.
#
# sudo bash n8n-sichern.sh
# sudo bash n8n-sichern.sh --passwort (zusätzlich verschlüsselt)
# sudo bash n8n-sichern.sh --ziel /root/umschlag.tar.gz --leise
#
# Es ändert nichts an der laufenden Installation und liest nur.
# Der Umschlag gehört VOM SERVER HERUNTER — sonst ist er im Ernstfall
# genau dort, wo er nicht mehr erreichbar ist.
set -Eeuo pipefail
VERZEICHNIS="/opt/n8n"
ZIEL=""
VERSCHLUESSELN=0
LEISE=0
BEHALTEN=14 # so viele Sicherungen bleiben im Verzeichnis liegen
if [[ -t 1 ]]; then
F_OK=$'\e[32m'; F_WARN=$'\e[33m'; F_LEISE=$'\e[2m'; F_AUS=$'\e[0m'
else
F_OK=""; F_WARN=""; F_LEISE=""; F_AUS=""
fi
ok() { (( LEISE )) || printf ' %s✓%s %s\n' "$F_OK" "$F_AUS" "$1"; }
info() { (( LEISE )) || printf ' %s·%s %s\n' "$F_LEISE" "$F_AUS" "$1"; }
warnung() { printf ' %s!%s %s\n' "$F_WARN" "$F_AUS" "$1" >&2; }
abbruch() { printf '\n✗ %s\n Nächster Schritt: %s\n\n' "$1" "$2" >&2; exit 1; }
while [[ $# -gt 0 ]]; do
case "$1" in
--verzeichnis) VERZEICHNIS="${2:?}"; shift 2 ;;
--ziel) ZIEL="${2:?}"; shift 2 ;;
--passwort) VERSCHLUESSELN=1; shift ;;
--leise) LEISE=1; shift ;;
-h|--hilfe|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) abbruch "Unbekannte Angabe: $1" "bash $0 --hilfe" ;;
esac
done
COMPOSE="${VERZEICHNIS}/docker-compose.yml"
UMGEBUNG="${VERZEICHNIS}/.env"
[[ -f "$COMPOSE" ]] || abbruch "${COMPOSE} fehlt." "Verzeichnis angeben: --verzeichnis /pfad/zu/n8n"
[[ -r "$UMGEBUNG" ]] || abbruch "${UMGEBUNG} nicht lesbar." "Mit sudo ausführen."
STAND="$(date '+%Y-%m-%d_%H%M')"
ORDNER="${VERZEICHNIS}/sicherung"
[[ -n "$ZIEL" ]] || ZIEL="${ORDNER}/n8n-${STAND}.tar.gz"
mkdir -p "$ORDNER"
ARBEIT="$(mktemp -d)"
trap 'rm -rf "$ARBEIT"' EXIT
PAKET="${ARBEIT}/n8n-${STAND}"
mkdir -p "$PAKET"
dc() { docker compose -f "$COMPOSE" "$@"; }
umg() { grep -E "^${1}=" "$UMGEBUNG" | head -1 | cut -d= -f2-; }
# 1 · Der Schlüssel und die Umgebung
cp "$UMGEBUNG" "${PAKET}/umgebung.env"
chmod 600 "${PAKET}/umgebung.env"
ok "Umgebung samt Verschlüsselungsschlüssel übernommen"
# 2 · Die Datenbank
DBID="$(dc ps -q db 2>/dev/null | head -1)"
if [[ -n "$DBID" ]]; then
if docker exec "$DBID" pg_dump -U "$(umg POSTGRES_USER)" -d "$(umg POSTGRES_DB)" \
> "${PAKET}/datenbank.sql" 2>/dev/null; then
ok "Datenbank gesichert ($(du -h "${PAKET}/datenbank.sql" | cut -f1))"
else
warnung "pg_dump fehlgeschlagen — Sicherung ist unvollständig"
fi
else
warnung "Datenbank-Container läuft nicht — keine Datenbanksicherung"
fi
# 3 · Die Abläufe als lesbare Dateien (ohne Zugangsdaten)
N8NID="$(dc ps -q n8n 2>/dev/null | head -1)"
if [[ -n "$N8NID" ]]; then
mkdir -p "${PAKET}/ablaeufe"
if docker exec "$N8NID" n8n export:workflow --all --pretty --separate --output=/ablaeufe/ >/dev/null 2>&1; then
cp -r "${VERZEICHNIS}/ablaeufe/." "${PAKET}/ablaeufe/" 2>/dev/null || true
ok "Abläufe als JSON gesichert ($(find "${PAKET}/ablaeufe" -name '*.json' | wc -l | tr -d ' ') Dateien)"
info "Zugangsdaten sind darin NICHT enthalten — sie stehen verschlüsselt in der Datenbank."
else
warnung "Export der Abläufe fehlgeschlagen — die Datenbanksicherung enthält sie trotzdem"
fi
fi
# 4 · Anleitung zum Zurückspielen
cat > "${PAKET}/ANLEITUNG.txt" <<TXT
Wiederherstellung — n8n, Stand ${STAND}
Voraussetzung: ein Server mit Docker, wie in Kurs 1 aufgesetzt.
1. Verzeichnis anlegen und diese Dateien hineinlegen:
mkdir -p /opt/n8n && cd /opt/n8n
2. Umgebung zurückspielen — MIT dem alten Verschlüsselungsschlüssel:
cp umgebung.env /opt/n8n/.env && chmod 600 /opt/n8n/.env
Ein anderer Schlüssel macht alle hinterlegten Zugänge unlesbar.
Das ist der häufigste Fehler beim Zurückspielen.
3. docker-compose.yml aus dem Repository holen (oder n8n-aufsetzen.sh laufen
lassen — es lässt eine bestehende .env unangetastet).
4. Nur die Datenbank starten und den Abzug einspielen:
docker compose up -d db
docker compose exec -T db psql -U n8n -d n8n < datenbank.sql
5. n8n starten und anmelden:
docker compose up -d
6. Die Probe: einen Ablauf von Hand auslösen und im Protokoll nachsehen,
ob er durchläuft. Erst dann war die Sicherung eine.
Was NICHT in diesem Paket ist: die Daten der Fremdsysteme (Postfach,
Datenbank der Anwendung, Dateiablagen). Die haben ihre eigene Sicherung.
TXT
# 5 · Einpacken
tar -czf "${ARBEIT}/paket.tar.gz" -C "$ARBEIT" "n8n-${STAND}"
if (( VERSCHLUESSELN )); then
command -v openssl >/dev/null || abbruch "openssl fehlt." "Ohne --passwort erneut ausführen."
openssl enc -aes-256-cbc -pbkdf2 -salt -in "${ARBEIT}/paket.tar.gz" -out "${ZIEL}.enc"
ZIEL="${ZIEL}.enc"
else
mv "${ARBEIT}/paket.tar.gz" "$ZIEL"
fi
chmod 600 "$ZIEL"
ok "Sicherung liegt hier: ${ZIEL} ($(du -h "$ZIEL" | cut -f1))"
# 6 · Alte Sicherungen aufräumen
ANZAHL="$(ls -1t "${ORDNER}"/n8n-*.tar.gz* 2>/dev/null | wc -l | tr -d ' ')"
if (( ANZAHL > BEHALTEN )); then
ls -1t "${ORDNER}"/n8n-*.tar.gz* | tail -n +$((BEHALTEN+1)) | xargs -r rm -f
info "Alte Sicherungen entfernt — die letzten ${BEHALTEN} bleiben liegen"
fi
(( LEISE )) || cat <<TXT
Der wichtigste Schritt fehlt noch: die Datei vom Server holen.
scp betreiber@$(umg DOMAIN 2>/dev/null || hostname):${ZIEL} ~/Desktop/
Und einmal im Quartal: auf einen leeren Container zurückspielen.
Eine Sicherung, die nicht zurückgespielt wurde, ist keine.
TXTdocker compose up -d db docker compose exec -T db psql -U n8n -d n8n < datenbank.sql docker compose up -d
sudo bash automatisierung/n8n-check.sh --domain ablauf.example.de
Serverprüfung — n8n — ablauf.example.de — 03.09.2026 09:14 Betrieb ✓ Installation /opt/n8n ✓ Container n8n läuft ✓ Container db läuft ✓ Dauerneustarts 0 ✓ n8n-Fassung 1.114.0 ✓ Port nach außen keiner — nur über Traefik ✓ Datenbank nach außen nicht erreichbar Schlüssel und Sicherung ✓ Umgebungsdatei nur für root lesbar ✓ Verschlüsselungsschlüssel gesetzt (64 Zeichen) ✓ Notfallumschlag 2 Tage alt ✓ Fingerabdruck Umschlag passt zum Schlüssel ✓ Sicherung 0 Tage alt Läufe ✓ Aufräumen der Läufe an (336 Stunden) ✓ Letzter Lauf vor 1 Stunden ✓ Abgebrochene Läufe (24 h) 0 Erreichbarkeit ✓ Oberfläche antwortet (200) ✓ Zertifikat gültig, noch 74 Tage Alles in Ordnung — 17 Punkte geprüft
#!/usr/bin/env bash
#
# n8n-check.sh — "Laufen meine Abläufe, und sind sie sicher?"
#
# Ein Befehl, eine Antwort. Läuft in ein paar Sekunden, ändert nichts.
# Jede Zeile ist entweder in Ordnung, ein Hinweis oder ein Problem —
# und jedes Problem nennt den nächsten Schritt.
#
# sudo bash n8n-check.sh
# sudo bash n8n-check.sh --domain ablauf.example.de (prüft zusätzlich das Zertifikat)
#
# Rückgabewert: 0 = alles in Ordnung, 1 = mindestens ein Problem.
# Damit taugt der Befehl auch für die Überwachung — und die Ausgabe lässt
# sich in der Akademie in die Phase einfügen, die die Nachweise abhakt.
set -uo pipefail
# Umlaute sollen die Spaltenbreite nicht verschieben: eine UTF-8-Umgebung wählen,
# damit die Shell Zeichen zählt und nicht Bytes.
if [[ "${LC_ALL:-${LANG:-}}" != *UTF-8* && "${LC_ALL:-${LANG:-}}" != *utf8* ]]; then
VERFUEGBAR="$(locale -a 2>/dev/null || true)"
for L in C.UTF-8 de_DE.UTF-8 en_US.UTF-8; do
case $'\n'"$VERFUEGBAR"$'\n' in *$'\n'"$L"$'\n'*) export LC_ALL="$L"; break ;; esac
done
fi
DOMAIN=""
VERZEICHNIS="/opt/n8n"
UMSCHLAG="/root/n8n-notfallumschlag.tar.gz"
STILLSTAND_STUNDEN=24 # so lange darf der letzte Lauf her sein
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="${2:-}"; shift 2 ;;
--verzeichnis) VERZEICHNIS="${2:-}"; shift 2 ;;
--umschlag) UMSCHLAG="${2:-}"; shift 2 ;;
-h|--hilfe|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) shift ;;
esac
done
if [[ -t 1 ]]; then
F_TITEL=$'\e[1m'; F_OK=$'\e[32m'; F_WARN=$'\e[33m'; F_FEHLER=$'\e[31m'; F_AUS=$'\e[0m'
else
F_TITEL=""; F_OK=""; F_WARN=""; F_FEHLER=""; F_AUS=""
fi
GEPRUEFT=0; HINWEISE=0; PROBLEME=0
declare -a SCHRITTE=()
titel() { printf '\n%s%s%s\n' "$F_TITEL" "$1" "$F_AUS"; }
# Auf 34 ZEICHEN auffüllen (nicht auf Bytes) — Umlaute sollen die Spalte nicht verschieben.
breit() { local x="$1" n; n=$(( 34 - ${#x} )); (( n < 0 )) && n=0; printf '%s%*s' "$x" "$n" ''; }
gut() { GEPRUEFT=$((GEPRUEFT+1)); printf ' %s✓%s %s %s\n' "$F_OK" "$F_AUS" "$(breit "$1")" "${2:-}"; }
hinweis() { GEPRUEFT=$((GEPRUEFT+1)); HINWEISE=$((HINWEISE+1)); printf ' %s!%s %s %s\n' "$F_WARN" "$F_AUS" "$(breit "$1")" "${2:-}"; [[ -n "${3:-}" ]] && SCHRITTE+=("$3"); }
schlecht(){ GEPRUEFT=$((GEPRUEFT+1)); PROBLEME=$((PROBLEME+1)); printf ' %s✗%s %s %s\n' "$F_FEHLER" "$F_AUS" "$(breit "$1")" "${2:-}"; [[ -n "${3:-}" ]] && SCHRITTE+=("$3"); }
printf '%s\n' "${F_TITEL}Serverprüfung — n8n — $(hostname) — $(date '+%d.%m.%Y %H:%M')${F_AUS}"
COMPOSE="${VERZEICHNIS}/docker-compose.yml"
UMGEBUNG="${VERZEICHNIS}/.env"
dc() { docker compose -f "$COMPOSE" "$@" 2>/dev/null; }
umg() { [[ -r "$UMGEBUNG" ]] && grep -E "^${1}=" "$UMGEBUNG" | head -1 | cut -d= -f2- || true; }
# ---------------------------------------------------------------- Betrieb
titel "Betrieb"
if ! command -v docker >/dev/null; then
schlecht "Docker" "nicht vorhanden" "Erst bootstrap.sh aus Kurs 1 laufen lassen"
elif [[ ! -f "$COMPOSE" ]]; then
schlecht "Installation" "${COMPOSE} fehlt" "n8n-aufsetzen.sh laufen lassen — oder --verzeichnis angeben"
else
gut "Installation" "$VERZEICHNIS"
ZUSTAND="$(dc ps --format '{{.Service}} {{.State}}')"
for DIENST in n8n db; do
ZEILE="$(grep -E "^${DIENST} " <<<"$ZUSTAND" | head -1)"
NAME="Container ${DIENST}"
if [[ -z "$ZEILE" ]]; then
schlecht "$NAME" "läuft nicht" "cd ${VERZEICHNIS} && docker compose up -d"
elif grep -q 'running' <<<"$ZEILE"; then
gut "$NAME" "läuft"
else
schlecht "$NAME" "${ZEILE#* }" "cd ${VERZEICHNIS} && docker compose logs --tail=50 ${DIENST}"
fi
done
CID="$(dc ps -q n8n | head -1)"
if [[ -n "$CID" ]]; then
NEUSTARTS="$(docker inspect -f '{{.RestartCount}}' "$CID" 2>/dev/null || echo 0)"
if (( NEUSTARTS > 5 )); then
schlecht "Dauerneustarts" "${NEUSTARTS} Neustarts" "docker logs --tail=80 ${CID} — meist fehlt eine Umgebungsvariable"
else
gut "Dauerneustarts" "${NEUSTARTS}"
fi
FASSUNG="$(docker inspect -f '{{.Config.Image}}' "$CID" 2>/dev/null)"
if [[ "$FASSUNG" == *:latest ]]; then
hinweis "n8n-Fassung" "latest" "Feste Fassung eintragen — sonst ändert sich beim nächsten Neustart unangekündigt alles"
else
gut "n8n-Fassung" "${FASSUNG##*:}"
fi
OFFEN="$(docker inspect -f '{{json .NetworkSettings.Ports}}' "$CID" 2>/dev/null)"
if grep -q 'HostPort' <<<"$OFFEN"; then
schlecht "Port nach außen" "5678 ist veröffentlicht" "In docker-compose.yml den ports-Abschnitt entfernen — Traefik erreicht n8n über das Docker-Netz"
else
gut "Port nach außen" "keiner — nur über Traefik"
fi
fi
DBID="$(dc ps -q db | head -1)"
if [[ -n "$DBID" ]]; then
if docker inspect -f '{{json .NetworkSettings.Ports}}' "$DBID" 2>/dev/null | grep -q 'HostPort'; then
schlecht "Datenbank nach außen" "veröffentlicht" "ports beim Dienst db entfernen — die Datenbank gehört ins interne Netz"
else
gut "Datenbank nach außen" "nicht erreichbar"
fi
fi
fi
# ------------------------------------------------------ Schlüssel und Sicherung
titel "Schlüssel und Sicherung"
if [[ ! -r "$UMGEBUNG" ]]; then
schlecht "Umgebungsdatei" "nicht lesbar" "Mit sudo ausführen — oder n8n-aufsetzen.sh laufen lassen"
else
RECHTE="$(stat -c '%a' "$UMGEBUNG" 2>/dev/null || stat -f '%OLp' "$UMGEBUNG" 2>/dev/null)"
if [[ "$RECHTE" == "600" ]]; then gut "Umgebungsdatei" "nur für root lesbar"
else schlecht "Umgebungsdatei" "Rechte ${RECHTE}" "chmod 600 ${UMGEBUNG}"; fi
SCHLUESSEL="$(umg N8N_ENCRYPTION_KEY)"
if [[ -z "$SCHLUESSEL" ]]; then
schlecht "Verschlüsselungsschlüssel" "nicht gesetzt" "N8N_ENCRYPTION_KEY setzen — sonst würfelt n8n bei jedem Start einen neuen"
elif (( ${#SCHLUESSEL} < 32 )); then
hinweis "Verschlüsselungsschlüssel" "kurz (${#SCHLUESSEL} Zeichen)" "Länger wählen: openssl rand -hex 32 — Achtung, ein Wechsel macht bestehende Zugänge unlesbar"
else
gut "Verschlüsselungsschlüssel" "gesetzt (${#SCHLUESSEL} Zeichen)"
fi
if [[ -f "$UMSCHLAG" ]]; then
ALTER=$(( ( $(date +%s) - $(stat -c %Y "$UMSCHLAG" 2>/dev/null || stat -f %m "$UMSCHLAG") ) / 86400 ))
if (( ALTER > 30 )); then
hinweis "Notfallumschlag" "${ALTER} Tage alt" "sudo bash n8n-sichern.sh — und die neue Datei vom Server holen"
else
gut "Notfallumschlag" "${ALTER} Tage alt"
fi
if [[ -n "$SCHLUESSEL" ]] && tar -xzOf "$UMSCHLAG" 2>/dev/null | grep -q "$SCHLUESSEL"; then
gut "Fingerabdruck" "Umschlag passt zum Schlüssel"
else
hinweis "Fingerabdruck" "nicht überprüfbar" "Umschlag neu erzeugen: sudo bash n8n-sichern.sh"
fi
else
schlecht "Notfallumschlag" "fehlt" "sudo bash n8n-sichern.sh — ohne den Schlüssel ist jede Sicherung wertlos"
fi
LETZTE="$(ls -1t "${VERZEICHNIS}"/sicherung/*.tar.gz 2>/dev/null | head -1)"
if [[ -n "$LETZTE" ]]; then
TAGE=$(( ( $(date +%s) - $(stat -c %Y "$LETZTE" 2>/dev/null || stat -f %m "$LETZTE") ) / 86400 ))
if (( TAGE > 2 )); then hinweis "Sicherung" "${TAGE} Tage alt" "Tägliche Sicherung einrichten (Coolify Scheduled Task oder cron)"
else gut "Sicherung" "${TAGE} Tage alt"; fi
else
hinweis "Sicherung" "keine gefunden" "sudo bash n8n-sichern.sh täglich laufen lassen — und den Rückweg einmal üben"
fi
fi
# ---------------------------------------------------------------- Läufe
titel "Läufe"
PRUNE="$(umg EXECUTIONS_DATA_PRUNE)"
FRIST="$(umg EXECUTIONS_DATA_MAX_AGE)"
if [[ "$PRUNE" == "true" ]]; then
gut "Aufräumen der Läufe" "an${FRIST:+ (${FRIST} Stunden)}"
else
hinweis "Aufräumen der Läufe" "aus" "EXECUTIONS_DATA_PRUNE=true setzen — sonst wachsen die Daten jedes Laufs unbegrenzt"
fi
if [[ -n "${DBID:-}" ]]; then
DB_USER="$(umg POSTGRES_USER)"; DB_NAME="$(umg POSTGRES_DB)"
psql_() { docker exec -i "$DBID" psql -U "${DB_USER:-n8n}" -d "${DB_NAME:-n8n}" -tA -c "$1" 2>/dev/null; }
LETZTER="$(psql_ "select extract(epoch from now() - max(\"startedAt\"))::int from execution_entity;")"
if [[ -n "$LETZTER" && "$LETZTER" =~ ^[0-9]+$ ]]; then
STD=$(( LETZTER / 3600 ))
if (( STD > STILLSTAND_STUNDEN )); then
schlecht "Letzter Lauf" "vor ${STD} Stunden" "Das ist der leise Stillstand. Auslöser prüfen — Station 05"
else
gut "Letzter Lauf" "vor ${STD} Stunden"
fi
else
hinweis "Letzter Lauf" "nicht ablesbar" "Noch kein Lauf, oder die Datenbank antwortet nicht"
fi
FEHLER="$(psql_ "select count(*) from execution_entity where status='error' and \"startedAt\" > now() - interval '24 hours';")"
if [[ -n "$FEHLER" && "$FEHLER" =~ ^[0-9]+$ ]]; then
if (( FEHLER > 0 )); then hinweis "Abgebrochene Läufe (24 h)" "${FEHLER}" "In n8n unter Ausführungen ansehen — und prüfen, ob der Fehler-Ablauf gemeldet hat"
else gut "Abgebrochene Läufe (24 h)" "0"; fi
fi
fi
# ---------------------------------------------------------- Erreichbarkeit
if [[ -n "$DOMAIN" ]]; then
titel "Erreichbarkeit"
CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${DOMAIN}/" 2>/dev/null)"
case "$CODE" in
200|401|302) gut "Oberfläche" "antwortet (${CODE})" ;;
000) schlecht "Oberfläche" "keine Antwort" "DNS, Traefik-Label und Container prüfen" ;;
*) hinweis "Oberfläche" "antwortet mit ${CODE}" "In Traefik nachsehen, ob der Router greift" ;;
esac
ENDE_ROH="$(echo | timeout 8 openssl s_client -servername "$DOMAIN" -connect "${DOMAIN}:443" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)"
if [[ -n "$ENDE_ROH" ]]; then
TAGE=$(( ( $(date -d "$ENDE_ROH" +%s 2>/dev/null || echo 0) - $(date +%s) ) / 86400 ))
if (( TAGE < 0 )); then schlecht "Zertifikat" "abgelaufen" "Domain in Coolify neu speichern — Traefik holt ein neues"
elif (( TAGE < 14 )); then hinweis "Zertifikat" "läuft in ${TAGE} Tagen ab" "Sollte sich selbst erneuern. Wenn nicht: Domain neu speichern"
else gut "Zertifikat" "gültig, noch ${TAGE} Tage"; fi
else
hinweis "Zertifikat" "keines abrufbar" "Kommt erst, wenn Traefik die Domain kennt"
fi
fi
# ---------------------------------------------------------------- Ergebnis
printf '\n%s' "$F_TITEL"
if (( PROBLEME > 0 )); then printf '%s%d Problem(e), %d Hinweis(e) — %d Punkte geprüft%s\n' "$F_FEHLER" "$PROBLEME" "$HINWEISE" "$GEPRUEFT" "$F_AUS"
elif (( HINWEISE > 0 )); then printf '%sKeine Probleme, %d Hinweis(e) — %d Punkte geprüft%s\n' "$F_WARN" "$HINWEISE" "$GEPRUEFT" "$F_AUS"
else printf '%sAlles in Ordnung — %d Punkte geprüft%s\n' "$F_OK" "$GEPRUEFT" "$F_AUS"; fi
if (( ${#SCHRITTE[@]} > 0 )); then
printf '\n%sNächste Schritte%s\n' "$F_TITEL" "$F_AUS"
N=1; for S in "${SCHRITTE[@]}"; do printf ' %d. %s\n' "$N" "$S"; N=$((N+1)); done
fi
printf '\n'
(( PROBLEME == 0 ))Zum Aushängen
Alle Diagramme
Dieselben Bilder wie im Kurs, hintereinander und druckbar. Sie stehen auf einer eigenen Seite — 11 Zeichnungen auf einmal machen jede andere Seite träge.
Material ist Werkzeug, nicht der Stoff. Skripte, Vorlagen und Ihre eigene Prüfliste nehmen Sie mit. Die Phasentexte bleiben im Kurs — sie werden gepflegt, und eine Kopie wäre schon morgen falsch.