Station 01/Aufsetzen in zwölf Minuten·Vorschau
Station 01 · Einrichten/Phase 2

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.

Der Film zu dieser Phase1:41 — mit Untertiteln. Er ersetzt den Text nicht, er geht ihm voraus.

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.

1

Die Skripte auf den Server bringen

auf dem eigenen Rechner
scp -r automatisierung betreiber@apps.example.de:~
2

Erst ansehen, was passieren würde

auf dem Server
sudo bash automatisierung/n8n-aufsetzen.sh \
  --domain ablauf.example.de --email ich@example.de --nur-pruefen
Der Trockenlauf ist kein Zusatz, sondern der erste Schritt. Er zeigt jeden Befehl und jede Datei, die entstehen würde — und ändert nichts. Genau das ist die Übung: erst lesen, was ein Skript vorhat, dann laufen lassen.
3

Scharf

auf dem Server
sudo bash automatisierung/n8n-aufsetzen.sh \
  --domain ablauf.example.de --email ich@example.de
4

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.

Diese Lücke ist der häufigste Anfängerfehler. Legen Sie das Konto an, bevor Sie Kaffee holen — nicht danach.

Was das Skript anlegt

WasWoWarum es wichtig ist
.env/opt/n8n/.env, nur für root lesbarEnthä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.gzSchlüssel und Datenbank in einer Datei — gehört vom Server herunter.
Das ganze Skript zum Mitlesen n8n-aufsetzen.sh 309 Zeilen — Kopierknopf oben rechts
n8n-aufsetzen.sh
#!/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:

/opt/n8n/.env
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
Der Verschlüsselungsschlüssel ist die halbe Installation. Er entschlüsselt alle hinterlegten Zugänge. Wird er verloren, ist jede Sicherung wertlos — die Daten sind vorhanden und unlesbar. Wird er ausgetauscht, ist jeder bestehende Zugang unbrauchbar. Übertragen Sie ihn jetzt in den Passwortspeicher des Unternehmens, nicht später.
  • Bevor es weitergeht
Auf Stufe 2 (Fortgeschritten) steht hier zusätzlich: was in der docker-compose.yml Zeile für Zeile steht — und was man ändern muss, wenn kein Coolify im Spiel ist
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./daten für alles, was n8n selbst behält, ./db für die Datenbank. Was nicht in einem Volume liegt, ist beim nächsten docker compose up weg.
  • 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.

Zugang erhalten
Fortschritt merken?Mit Zugang bleiben Häkchen und erledigte Phasen am Konto — auf jedem Gerät.