Self-Hosting mit Docker Compose — n8n, Postgres und Caddy
Artikel 2 · Serie: Einstieg in n8n
Der Prolog hat die strategischen Gründe für n8n abgesteckt. Artikel 1 hat die Architektur erklärt: Editor, Execution Engine, Task Runners, das Item-Modell. Dieser Artikel macht eine lauffähige Instanz daraus. Das Ziel ist eine n8n-Installation, die von Tag eins mit Postgres betrieben wird, HTTPS über Caddy bekommt und deren Volumes so organisiert sind, dass ein Neustart keine Daten verliert. Am Ende des Artikels hat man außerdem den ersten eigenen Workflow angelegt und getestet. Der Code zu diesem Artikel liegt im Demo-Repo auf Codeberg, Tag v0.2: codeberg.org/rotecodefraktion/n8n-einstieg.
Warum Postgres, nicht SQLite
n8n verwendet SQLite als Standard-Datenbank, wenn keine andere konfiguriert wird. Für einen ersten Einblick ist das akzeptabel. Für alles, was länger als einen Nachmittag läuft, ist es das nicht.
SQLite speichert die gesamte Datenbank in einer einzigen Datei. Mehrere Schreibzugriffe gleichzeitig sperren sich gegenseitig. Das ist bei einem einzelnen n8n-Prozess kein Problem, solange Ausführungen sequenziell ablaufen. Sobald aber mehrere Workflows parallel laufen, sobald n8n im Queue-Mode mit separaten Workern betrieben wird, oder sobald die Ausführungshistorie auf tausende Einträge anwächst, zeigt SQLite sein strukturelles Limit.
Die Migration von SQLite auf Postgres ist möglich, aber kein Vergnügen. n8n bietet kein eingebautes Migrations-Tool. Man exportiert Workflows manuell als JSON, richtet Postgres ein, importiert zurück und hofft, dass keine Credential-Referenz bei der Migration abbricht. Diesen Schritt spart man sich, wenn Postgres von Anfang an der Default ist.
Postgres ist auch die Voraussetzung für den Queue-Mode. In Artikel 8 wird n8n mit separaten Worker-Prozessen und Redis betrieben. Das Compose-Setup dieses Artikels legt dafür die Grundlage, ohne dass man später etwas umbauen muss.
Docker-Compose-Setup
Die vollständige Datei liegt im Repo unter docker/docker-compose.yml. Hier der kommentierte Aufbau:
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres-init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: n8nio/n8n:2.21.4
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
WEBHOOK_URL: ${WEBHOOK_URL}
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
N8N_DIAGNOSTICS_ENABLED: ${N8N_DIAGNOSTICS_ENABLED}
volumes:
- n8n-data:/home/node/.n8n
expose:
- "5678"
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- n8n
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
volumes:
postgres-data:
n8n-data:
caddy-data:
caddy-config:
Ein paar Details, die nicht selbsterklärend sind:
depends_on mit condition: service_healthy stellt sicher, dass n8n erst startet, wenn Postgres wirklich bereit ist, Verbindungen anzunehmen. Das healthcheck-Kommando auf dem Postgres-Container prüft das mit pg_isready. Ohne diese Bedingung kann n8n beim Start gegen einen noch nicht fertigen Postgres laufen und abstürzen, was Docker Compose als Crash behandelt.
n8n bindet nur expose: "5678", nicht ports. Der Port ist damit innerhalb des Compose-Netzwerks erreichbar, aber nicht von außen. Caddy ist der einzige Dienst, der nach außen exponiert ist, auf Port 80 und 443.
n8n-data ist das Volume, das die komplette n8n-Konfiguration enthält: den Encryption Key Cache, die lokale Konfiguration, Session-Daten. Workflows und Credentials liegen in Postgres, aber das Volume muss trotzdem gesichert werden.
Reverse Proxy mit Caddy
Caddy ist ein Webserver, der HTTPS automatisch über das ACME-Protokoll (Let’s Encrypt oder ZeroSSL) ohne manuelle Zertifikat-Konfiguration liefert. Das bedeutet: Wer eine öffentlich erreichbare Domain hat und Port 80 und 443 offen sind, bekommt automatisch ein gültiges TLS-Zertifikat ohne einen einzigen certbot-Befehl.
Die Caddyfile im Repo hat zwei Varianten. Für den lokalen Entwicklungsbetrieb:
localhost {
reverse_proxy n8n:5678
tls internal
}
tls internal lässt Caddy ein selbst signiertes Zertifikat erzeugen. Safari und Chrome vertrauen diesem Zertifikat standardmäßig nicht — auch nicht für localhost.
macOS: https://localhost in Safari zum Laufen bringen
Safari liest Zertifikatsvertrauen aus dem macOS-Keychain. Caddys selbst signiertes Zertifikat ist dort nicht eingetragen, daher erscheint die Sicherheitswarnung — oder https://localhost lädt schlicht nicht.
Die saubere Lösung: Caddys lokale CA einmalig dem System-Keychain beibringen. Das CA-Zertifikat liegt im caddy-data Volume und lässt sich direkt aus dem laufenden Container holen:
# Zertifikat aus dem Container kopieren
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-local-ca.crt
# Als vertrauenswürdig im macOS-Keychain markieren
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain ./caddy-local-ca.crt
Danach Safari neu starten — https://localhost funktioniert ohne Warnung.
Wichtig: Das Zertifikat ist ans caddy-data Volume gebunden. Solange das Volume existiert, bleibt alles funktionsfähig. Wird das Volume gelöscht (docker compose down -v), generiert Caddy eine neue CA und der Schritt muss einmal wiederholt werden. Die temporäre Datei caddy-local-ca.crt kann danach gelöscht werden.
Wer den Schritt umgehen will, kann im lokalen Betrieb auch direkt auf http://127.0.0.1:5678 zugreifen — Port 5678 ist im Compose-Setup intern exponiert. Für die n8n-UI im Browser genügt das; HTTPS wird erst für externe Webhook-Empfänger relevant.
Für den produktiven Betrieb auf einer eigenen Domain:
yourdomain.com {
reverse_proxy n8n:5678
}
Kein tls-Block nötig. Caddy holt das Zertifikat automatisch. Einzige Voraussetzung: Die Domain zeigt per A-Record auf die Server-IP, und Port 80 ist erreichbar, damit ACME den Domain-Ownership-Nachweis durchführen kann.
Caddy verwaltet den Zertifikat-Lebenszyklus automatisch. Let’s Encrypt-Zertifikate laufen nach 90 Tagen ab; Caddy erneuert sie im Hintergrund, bevor das passiert.
Environment-Variablen die zählen
Die .env.example-Datei im Repo listet alle relevanten Variablen. Die drei, bei denen Fehler teuer werden:
N8N_ENCRYPTION_KEY. n8n verschlüsselt alle gespeicherten Credentials mit diesem Schlüssel. Credentials sind alles, was ein Benutzer in n8n unter „Credentials" anlegt: API-Keys, OAuth-Tokens, Datenbank-Passwörter. Wird der Encryption Key bei einem Neustart geändert oder fehlt er, sind alle gespeicherten Credentials unlesbar. n8n startet zwar, aber jeder Workflow, der auf Credentials zugreift, schlägt fehl. Der Schlüssel muss einmalig generiert, sicher aufbewahrt und nie in das Repo eingecheckt werden. Empfohlen: mindestens 32 zufällige Bytes, base64-kodiert. Ein passender Befehl:
openssl rand -base64 32
WEBHOOK_URL. n8n zeigt in der UI eine Webhook-URL an, wenn man einen Webhook-Trigger anlegt. Ohne korrekte Konfiguration zeigt n8n hier die IP-Adresse des Containers oder localhost. Das ist für externe Dienste nicht erreichbar. Für den rein lokalen Betrieb genügt http://localhost:5678 oder die Caddy-Domain. Sobald externe Dienste Webhooks liefern sollen (GitHub Actions, Stripe, ein Support-System), muss die öffentliche URL gesetzt sein. Für Tests ohne öffentliche IP ist ein ngrok-Tunnel oder ein Cloudflare-Tunnel die einfachste Lösung.
ngrok-Tunnel einrichten
ngrok leitet einen öffentlichen HTTPS-Endpunkt an einen lokalen Port weiter — ohne eigene Domain, ohne Portweiterleitung im Router. Sinnvoll wenn ein externer Dienst (GitHub, Stripe, ein Support-System) aktiv einen Webhook an die lokale Instanz schicken soll.
Einschränkung Free-Plan: Beim kostenlosen ngrok-Account ändert sich die zugewiesene URL bei jedem Neustart von ngrok http. Das bedeutet: nach jedem Neustart muss WEBHOOK_URL in der .env aktualisiert und n8n neu gestartet werden — und der externe Dienst braucht die neue URL. Für kurze Testsessions akzeptabel, für dauerhaftes lokales Entwickeln unpraktisch. Wer eine stabile URL braucht, nimmt einen ngrok-Bezahlplan oder Cloudflare Tunnel (kostenlos, stabile URL).
Für rein lokale Tests mit curl oder Postman ist ngrok nicht nötig — http://127.0.0.1:5678 reicht.
Installation:
# macOS
brew install ngrok
# oder direkt von ngrok.com/download
Einmaliges Setup (kostenloses Konto auf ngrok.com erforderlich):
ngrok config add-authtoken <dein-token>
Tunnel starten (während Docker Compose läuft):
ngrok http 5678
ngrok zeigt die zugewiesene URL an, zum Beispiel https://a1b2c3d4.ngrok.io. Diese URL in WEBHOOK_URL eintragen:
WEBHOOK_URL=https://a1b2c3d4.ngrok.io
Danach n8n neu starten, damit die neue URL übernommen wird:
docker compose restart n8n
Achtung: Die URL ändert sich bei jedem ngrok http-Aufruf neu (kostenloses Konto). Für dauerhaft stabile URLs bietet ngrok kostenpflichtige Pläne mit festen Subdomains. Cloudflare Tunnel ist eine kostenlose Alternative mit stabiler URL.
GENERIC_TIMEZONE. n8n hat intern keinen Standort-Kontext. Zeitgesteuerte Trigger und alle datumsbezogenen Operationen richten sich nach der gesetzten Timezone. Ohne diese Variable läuft n8n auf UTC. Das ist kein Fehler, aber alle Schedule-Trigger laufen dann in UTC, was mit lokalen Zeiten in Ticket-Timestamps oder Geschäftszeiten-Logik Probleme geben kann.
N8N_DIAGNOSTICS_ENABLED. Standardmäßig sendet n8n Telemetrie an n8n.io: welche Knoten genutzt werden, Fehlerquoten, Workflow-Größen. Wer das nicht will, setzt N8N_DIAGNOSTICS_ENABLED=false. Für produktive Setups in Unternehmensumgebungen ist das in der Regel eine Pflicht.
Die Datenbank-Credentials (POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD) sind geradlinig. Das postgres-init/01-init.sql-Script im Repo legt beim ersten Start einen dedizierten n8n-User und eine Datenbank an und setzt die nötigen Berechtigungen.
Setup-Script für die .env
Statt die .env manuell aus der .env.example zu kopieren und zu befüllen, liegt im Repo ein interaktives Script, das alle Parameter abfragt, sichere Werte generiert und die Datei direkt anlegt:
bash scripts/setup-env.sh
Das Script geht die Pflichtfelder der Reihe nach durch. Postgres-Passwort und Encryption Key werden via openssl rand generiert und als Default vorgeschlagen — man kann sie übernehmen oder eigene Werte eingeben. Am Ende zeigt das Script den generierten Encryption Key nochmals explizit an, zusammen mit dem Hinweis, ihn in einem Passwort-Manager zu sichern. Die fertige .env bekommt chmod 600.
Wer die Defaults übernimmt, hat nach dem Script eine produktionsnah konfigurierte .env und kann direkt mit docker compose up -d weitermachen.
Volumes und Persistenz
Vier Volumes werden in diesem Setup verwendet:
| Volume | Inhalt | Backup-Priorität |
|---|---|---|
postgres-data | Workflows, Ausführungshistorie, Credentials | Hoch |
n8n-data | n8n-Konfiguration, Key Cache | Mittel |
caddy-data | TLS-Zertifikate, ACME-State | Niedrig |
caddy-config | Caddy-Runtime-Konfig | Niedrig |
Workflows und Credentials liegen in Postgres, nicht in n8n-data. Das bedeutet: postgres-data ist das kritische Volume. Ein Verlust dieses Volumes bedeutet Verlust aller Workflows und aller Credentials. Caddy-Zertifikate werden automatisch erneuert; ein Verlust von caddy-data erzwingt nur eine neue ACME-Anfrage.
Für Backups im produktiven Betrieb genügt ein pg_dump-Cronjob:
docker exec postgres pg_dump -U ${POSTGRES_USER} ${POSTGRES_DB} > backup-$(date +%Y%m%d).sql
Erster Login, erster Workflow
Nach docker compose up -d im docker/-Verzeichnis startet n8n. Die Logs zeigen, ob Postgres-Verbindung und Encryption Key korrekt geladen wurden:
docker compose logs n8n | grep -E "Database|Encryption|Starting"
n8n ist erreichbar unter der konfigurierten Domain oder https://localhost. Beim ersten Aufruf erscheint ein Setup-Wizard: E-Mail, Passwort, Name für den Owner-Account. Dieser Account ist der einzige Admin; weitere Nutzer können später eingeladen werden.
Nach dem Login: Workflow anlegen. Oben links auf das n8n-Logo, dann „New Workflow". Der erste Workflow wird ein Smoke-Test: er läuft manuell und prüft, ob n8n überhaupt HTTP-Requests nach außen machen kann.
Einen „Manual Trigger"-Knoten hinzufügen. Danach einen „HTTP Request"-Knoten verbinden. Als URL https://httpbin.org/get eintragen, Methode GET. Rechts oben auf „Execute Workflow" klicken. Der HTTP-Request-Knoten sollte den JSON-Response von httpbin zeigen, inklusive der eigenen IP-Adresse als Absender. Damit ist bestätigt: n8n läuft, es kommt aus dem Container heraus, Postgres hält den Workflow.
Smoke-Test als Script
Das Repo enthält scripts/smoke-test.sh, der denselben Check automatisiert. Relevant für CI und für die Überprüfung nach einem Neustart:
#!/usr/bin/env bash
set -euo pipefail
COMPOSE_DIR="$(cd "$(dirname "$0")/../docker" && pwd)"
N8N_URL="${WEBHOOK_URL:-https://localhost}"
RETRY_MAX=20
RETRY_INTERVAL=5
echo "Starting services..."
docker compose -f "$COMPOSE_DIR/docker-compose.yml" up -d
echo "Waiting for n8n to become healthy..."
for i in $(seq 1 $RETRY_MAX); do
STATUS=$(docker compose -f "$COMPOSE_DIR/docker-compose.yml" ps --format json \
| python3 -c "import sys,json; data=[json.loads(l) for l in sys.stdin if l.strip()]; \
svc=[s for s in data if s.get('Service')=='n8n']; \
print(svc[0].get('Health','unknown') if svc else 'not-found')" 2>/dev/null || echo "unknown")
if [[ "$STATUS" == "healthy" ]]; then
echo "n8n is healthy."
break
fi
if [[ $i -eq $RETRY_MAX ]]; then
echo "n8n did not become healthy in time." >&2
docker compose -f "$COMPOSE_DIR/docker-compose.yml" logs n8n | tail -30 >&2
exit 1
fi
echo "Waiting... ($i/$RETRY_MAX)"
sleep "$RETRY_INTERVAL"
done
echo "Checking n8n HTTP endpoint..."
HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" "$N8N_URL/healthz" || echo "000")
if [[ "$HTTP_CODE" != "200" ]]; then
echo "Expected HTTP 200 from /healthz, got $HTTP_CODE" >&2
exit 1
fi
echo "HTTP 200 from $N8N_URL/healthz — smoke test passed."
Der Smoke-Test prüft drei Dinge: Container starten, n8n wird healthy, der /healthz-Endpoint antwortet mit 200. n8n exponiert /healthz seit Version 0.214 ohne Authentifizierung; Caddy leitet die Anfrage durch. Wenn der Test auf einem frischen Setup unter einer Minute läuft, ist das Setup korrekt.
Artikel 3: Testdaten, weil echte nicht gehen
Das Setup steht. Damit kann n8n Workflows ausführen, Webhooks empfangen und Credentials verschlüsselt speichern. Was noch fehlt, ist das, womit die Workflows arbeiten sollen. In diesem Demo-Projekt sind das Support-Tickets: Kategorie, Sprache, Priorität, Freitext. Echte Tickets aus einem produktiven System kommen nicht ins Repo, das wäre ein Datenschutzproblem. Wie man einen realistischen synthetischen Datensatz baut, der statistisch ausgewogen ist, mehrsprachig funktioniert und reproduzierbar ist, beschreibt Artikel 3.
Den Stand dieses Artikels im Demo-Repo findet man unter Tag v0.2 auf Codeberg: codeberg.org/rotecodefraktion/n8n-einstieg.