containers
guide
containers · notes · self-hosted

Joplin Server —
Sync via HTTP, Web-UI via HTTPS

Joplin Server ist die eleganteste Art Notizen self-hosted zu synchronisieren — solange man nicht versucht HTTPS für den Sync-Client zu verwenden. Die Desktop-App nutzt intern Node.js und ignoriert den Windows-Zertifikatspeicher komplett. mkcert-Zertifikate werden beim Sync still abgelehnt. Die Lösung: Sync über HTTP, Web-UI über HTTPS — zwei getrennte Zugangswege, einer für den Client, einer für den Browser. Dieser Guide beschreibt genau dieses Setup.

Joplin Server PostgreSQL Docker Compose mkcert ⚠ Node.js ignoriert mkcert ✓ HTTP-Sync-Workaround
01 Architektur 02 Deployment 03 FortiGate 04 Nginx 05 Clients einrichten 06 Backup 07 Fallstricke
01 — Architektur

Zwei Zugangswege, ein Server

Das Kernproblem: Joplin Desktop nutzt Node.js für HTTPS-Verbindungen und liest dabei nicht den Windows-Zertifikatspeicher aus. Eine selbstsignierte CA — egal ob mit mkcert erstellt und korrekt im System installiert — wird vom Sync-Client ignoriert. Die Fehlermeldung lautet unable to verify the first certificate.

Die pragmatische Lösung: zwei getrennte Zugangswege. Der Sync-Client spricht direkt über HTTP auf Port 22300 mit dem Joplin Server. Der Browser nutzt die Web-UI über HTTPS via Nginx — dort funktioniert mkcert problemlos.

⚡ Sync-Client (Desktop / Mobile)
Joplin AppHTTP Port 22300
    ▼
FortiGate WAN
    │  VIP-Joplin
    │  → 10.0.1.83:22300Joplin Server
  (direkt, kein Nginx)
          
🌐 Web-UI (Browser)
BrowserHTTPS Port 443
    ▼
Pi-hole DNS
    │  joplin.local → FG-WAN
    ▼
FortiGate → Nginx
    │  VIP-HTTPS → Server1
    │  proxy_pass → 10.0.1.83:22300Joplin Server
  (via Nginx, SSL-terminiert)
          
Warum HTTP für Sync akzeptabel ist

Der Sync läuft ausschließlich im lokalen Heimnetz — niemals über das Internet. Alle Daten sind zusätzlich End-to-End-verschlüsselt durch Joplins eingebaute E2EE-Verschlüsselung. Selbst wenn jemand den HTTP-Traffic mitlesen könnte, sähe er nur verschlüsselte Blöcke. Das HTTP-Risiko ist im LAN-Kontext vertretbar.

02 — Deployment

Docker Compose auf Server3

Joplin Server läuft zusammen mit PostgreSQL als Datenbank auf Server3. Wichtig: APP_BASE_URL muss exakt der URL entsprechen die im Sync-Client eingetragen wird. Jede Abweichung führt zu Invalid Origin-Fehlern beim Login.

/opt/joplin/docker-compose.yml Server3 — 10.0.1.83
services:
  joplin:
    image: joplin/server:latest
    container_name: joplin
    restart: unless-stopped
    ports:
      # Auf Server-IP binden, nicht 127.0.0.1 !
      # Nginx auf Server1 muss von außen drauf zugreifen können
      - "10.0.1.83:22300:22300"
    environment:
      - APP_PORT=22300
      # APP_BASE_URL = URL die der Sync-Client einträgt
      # Muss exakt übereinstimmen — kein trailing slash, kein https!
      - APP_BASE_URL=http://203.0.113.10:22300
      - DB_CLIENT=pg
      - POSTGRES_PASSWORD=sicheres-passwort
      - POSTGRES_DATABASE=joplin
      - POSTGRES_USER=joplin
      - POSTGRES_PORT=5432
      - POSTGRES_HOST=joplin-db
    depends_on:
      - joplin-db

  joplin-db:
    image: postgres:16
    container_name: joplin-db
    restart: unless-stopped
    environment:
      - POSTGRES_PASSWORD=sicheres-passwort
      - POSTGRES_DATABASE=joplin
      - POSTGRES_USER=joplin
    volumes:
      - ./data/postgres:/var/lib/postgresql/data

networks:
  default:
    name: joplin-net
1
Verzeichnis anlegen & starten
mkdir -p /opt/joplin/data/postgres — dann docker compose up -d. Beim ersten Start initialisiert PostgreSQL die Datenbank, Joplin Server wartet automatisch.
2
Admin-Account einrichten
Web-UI unter https://joplin.local öffnen. Standard-Login: admin@localhost / admin. Sofort Passwort ändern und eigenen User anlegen.
3
Sync-Client einrichten
In der Desktop-App: Einstellungen → Synchronisation → Joplin Server. URL: http://203.0.113.10:22300HTTP, nicht HTTPS. Dann Klick auf "Synchronisieren prüfen".
4
Notizen migrieren (falls vorhanden)
Vorhandene Notizen zuerst als JEX exportieren (Datei → Exportieren → JEX), dann importieren (Datei → Importieren → JEX), danach Sync anstoßen. Mobile App ebenfalls auf HTTP-URL umstellen.
03 — FortiGate

VIP & Firewall Policy für Sync

Der Sync-Client braucht einen direkten Weg zu Joplin Server — vorbei an Nginx. Dafür wird ein eigener VIP für Port 22300 angelegt, der direkt auf Server3 zeigt.

ParameterWert
VIP-NameVIP-Joplin
External IP203.0.113.10 (FortiGate WAN)
External Interfacewan ← nicht "wan1"
Mapped IP10.0.1.83 (Server3)
External Port22300
Map to Port22300
FortiOS CLI VIP + Policy für Joplin Sync
# VIP anlegen
config firewall vip
    edit "VIP-Joplin"
        set extip      203.0.113.10
        set extintf    "wan"
        set portforward enable
        set protocol   tcp
        set extport    22300
        set mappedip   10.0.1.83
        set mappedport 22300
    next
end

# Firewall Policy
config firewall policy
    edit 0
        set name     "Joplin-Sync"
        set srcintf  "wan"
        set dstintf  "lan"
        set srcaddr  "all"
        set dstaddr  "VIP-Joplin"
        set action   accept
        set service  "TCP_22300"
        set nat      disable
    next
end
04 — Nginx

Web-UI unter joplin.local

Nginx auf Server1 proxied die Web-UI über HTTPS. Da Joplin Server intern auf HTTP läuft, terminiert Nginx hier SSL und leitet unverschlüsselt weiter — klassisches SSL-Offloading. Für den Browser ist alles grün, der Sync-Client weiß davon nichts.

/opt/docker/nginx/conf.d/joplin.local.conf Server1 — Nginx
server {
    listen 443 ssl;
    server_name joplin.local;

    ssl_certificate     /etc/nginx/certs/homelab.pem;
    ssl_certificate_key /etc/nginx/certs/homelab-key.pem;

    location / {
        proxy_pass         http://10.0.1.83:22300;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        # WebSocket für Echtzeit-Updates
        proxy_set_header   Upgrade           $http_upgrade;
        proxy_set_header   Connection        "upgrade";
    }
}
Pi-hole DNS-Eintrag

joplin.local → 203.0.113.10 — wie alle anderen lokalen Domains auf die FortiGate WAN-IP, nicht auf Server1 direkt. FortiGate leitet via VIP-HTTPS auf Nginx, Nginx proxy_pass auf Server3.

05 — Clients einrichten

Desktop, Mobile & Web-UI

ClientSync-URLProtokollZertifikat nötig?
Desktop (Windows/Mac/Linux) http://203.0.113.10:22300 HTTP nein
Mobile (iOS / Android) http://203.0.113.10:22300 HTTP nein
Browser (Web-UI) https://joplin.local HTTPS mkcert CA installieren
E2EE — Ende-zu-Ende-Verschlüsselung aktivieren

Da Sync über HTTP läuft, ist E2EE besonders empfehlenswert. In der Desktop-App unter Extras → Verschlüsselung → Aktivieren. Alle Notizen werden vor dem Sync verschlüsselt — der Server sieht nur Chiffrat, nie Klartext. Master-Passwort sicher aufbewahren — ohne es sind verschlüsselte Notizen unwiederbringlich verloren.

06 — Backup

PostgreSQL automatisch sichern

Alle Notizen liegen in PostgreSQL. Ein täglicher Cron-Job auf Server3 sichert die Datenbank und räumt alte Backups automatisch auf.

crontab -e Server3 — tägliches Backup, 14 Tage Retention
# Backup-Verzeichnis anlegen (einmalig)
# mkdir -p /opt/joplin/backups

# Täglich um 03:00 Uhr
0 3 * * * docker exec joplin-db pg_dump -U joplin joplin \
  > /opt/joplin/backups/joplin_$(date +\%Y\%m\%d).sql \
  && find /opt/joplin/backups -name "*.sql" -mtime +14 -delete
Update-Prozess

cd /opt/joplin && docker compose pull && docker compose down && docker compose up -d — PostgreSQL-Daten liegen im bind-mount ./data/postgres und bleiben beim Update erhalten. Vorher ein manuelles Backup empfehlenswert.

07 — Fallstricke

Was schiefgehen kann

Sync schlägt fehl: "unable to verify the first certificate"
Node.js in der Joplin Desktop-App liest den Windows-Zertifikatspeicher nicht aus. mkcert-Zertifikate werden ignoriert, egal wie korrekt sie installiert sind. Einzige Lösung: Sync-URL auf http:// umstellen, nicht https://.
"Invalid Origin" beim Login in der Web-UI
APP_BASE_URL in der docker-compose.yml stimmt nicht mit der URL überein die der Browser oder Client verwendet. Beide müssen exakt übereinstimmen — inklusive Port, ohne trailing slash. Nach Änderung Container neu starten: docker compose down && docker compose up -d.
Web-UI erreichbar, aber Sync schlägt fehl
Häufig: Client-URL zeigt auf https://joplin.local statt auf http://203.0.113.10:22300. Die Web-UI läuft über Nginx + HTTPS, der Sync-Endpunkt ist direkt Port 22300 über die FortiGate — zwei verschiedene Wege.
Nginx kann Joplin Server nicht erreichen (502 Bad Gateway)
Port-Binding in Docker ist auf 127.0.0.1:22300 gesetzt statt auf die Server-IP. Nginx läuft auf Server1, Joplin auf Server3 — 127.0.0.1 hilft dabei nicht. In der docker-compose.yml auf 10.0.1.83:22300:22300 ändern.
Nach Migration: Notizen doppelt oder Sync-Konflikte
Reihenfolge beim Migrieren wichtig: zuerst JEX exportieren, dann Sync-Ziel ändern, dann JEX importieren, dann erst Sync anstoßen. Wenn der Sync vor dem Import läuft und der Server bereits Notizen hat, entstehen Duplikate. Im Zweifel Server-Datenbank leeren und sauber neu starten.