Aufsetzen in zwölf Minuten
Ein Skript, ein Trockenlauf, ein scharfer Lauf. Danach steht n8n hinter HTTPS und hat einen Schlüssel, der nicht verlorengehen darf.
Das Werkzeugpaket zu diesem Kurs liegt im Repository unter automatisierung/. Vier Skripte, die alle beliebig oft laufen dürfen: Beim zweiten Mal ändern sie nichts und ergänzen nur, was fehlt.
Die Skripte auf den Server bringen
scp -r automatisierung betreiber@apps.example.de:~
Erst ansehen, was passieren würde
sudo bash automatisierung/n8n-aufsetzen.sh \ --domain ablauf.example.de --email ich@example.de --nur-pruefen
Scharf
sudo bash automatisierung/n8n-aufsetzen.sh \ --domain ablauf.example.de --email ich@example.de
Das erste Konto anlegen — und zwar sofort
https://ablauf.example.de aufrufen. Wer zuerst kommt, wird Eigentümer. Das sollten Sie sein und nicht der Zufall — zwischen dem Start des Containers und Ihrer Anmeldung steht die Oberfläche offen im Netz.
Was das Skript anlegt
| Was | Wo | Warum es wichtig ist |
|---|---|---|
.env | /opt/n8n/.env, nur für root lesbar | Enthält den Verschlüsselungsschlüssel. Ein zweiter Lauf rührt ihn nicht an. |
docker-compose.yml | /opt/n8n/ | n8n und PostgreSQL, Traefik-Labels, internes Netz für die Datenbank. |
daten/, db/, ablaeufe/ | /opt/n8n/ | Alles, was einen Neustart überleben muss. |
| Der Notfallumschlag | /root/n8n-notfallumschlag.tar.gz | Schlüssel und Datenbank in einer Datei — gehört vom Server herunter. |
Das ganze Skript zum Mitlesen n8n-aufsetzen.sh 309 Zeilen — Kopierknopf oben rechts
#!/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}
ENDTEXT
Ohne Skript: dieselbe Sache von Hand
Wer lieber selbst tippt oder eine andere Umgebung hat, braucht genau zwei Dateien. Die Werte in spitzen Klammern ersetzen:
N8N_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
- Bevor es weitergeht
WarumDie docker-compose.yml, Zeile für Zeile
Vier Stellen tragen die ganze Datei:
depends_on: condition: service_healthy— n8n startet erst, wenn die Datenbank antwortet. Ohne das scheitert der erste Start in etwa der Hälfte der Fälle, und man sucht den Fehler an der falschen Stelle.- Zwei Volumes —
./datenfür alles, was n8n selbst behält,./dbfür die Datenbank. Was nicht in einem Volume liegt, ist beim nächstendocker compose upweg. - Die Traefik-Labels — sie sind kein Docker-Merkmal, sondern Notizzettel für den Proxy: unter welchem Namen, auf welchem Port, mit welchem Zertifikat. Kein Traefik im Netz? Dann sind die Labels wirkungslos, aber harmlos.
restart: unless-stopped— nach einem Neustart des Servers ist alles wieder da, außer jemand hat den Container ausdrücklich angehalten.
TiefeWas beim ersten Start in den Volumes entsteht
Im ersten Lauf legt n8n seine Tabellen an (eine Wanderung, die je nach Fassung ein bis zwei Minuten dauert) und schreibt in daten/ unter anderem config — eine kleine Datei mit einem Verschlüsselungsschlüssel, falls in der Umgebung keiner steht.
Genau hier entsteht der häufigste stille Fehler: Wer N8N_ENCRYPTION_KEY nicht setzt, bekommt einen gewürfelten Schlüssel in dieser Datei. Der funktioniert — bis das Volume verlorengeht oder jemand umzieht. Dann sind alle Zugänge unlesbar, und der Schlüssel, den man bräuchte, war nie irgendwo notiert.
Deshalb setzt das Skript den Schlüssel ausdrücklich in die Umgebung und rührt ihn bei jedem weiteren Lauf nicht mehr an.