Paperless-ngx ist in einer Stunde aufgesetzt: Docker-Compose-Datei, consume-Ordner, fertig. Der Dienst läuft, du wirfst die ersten Scans rein, die Volltextsuche funktioniert. Alles fühlt sich gelöst an.

Dann kommt Woche zwei, und plötzlich stehst du an drei Stellen gleichzeitig an:

  1. Erreichbarkeit. Das Webinterface hängt an http://…:8091 — IP und Port. Kein sauberes HTTPS, und der zentrale Reverse Proxy im Netz soll die Instanz eigentlich gar nicht sehen.
  2. Anmeldung. Du hast einen lokalen Superuser mit allen Dokumenten darin. Jetzt willst du dich über den zentralen Verzeichnisdienst anmelden — und merkst, dass daran mehr hängt als ein zweiter Login.
  3. OCR. Ein dicker Scanner-Stapel crasht den Import reproduzierbar, ohne brauchbare Fehlermeldung. Der Container-OOM-Killer schlägt zu, während du auf Zeitüberschreitung tippst.

Die drei Baustellen hängen enger zusammen, als sie aussehen: Punkt 2 braucht Punkt 1 (ein SSO-Redirect funktioniert nur, wenn die Instanz wirklich über HTTPS erreichbar ist), und Punkt 1 plus Punkt 3 kommen sich beim Container-Recreate in die Quere. Dieser Guide zieht alle drei in einem Rutsch durch — mit den Werten und Stolpersteinen, die tatsächlich aufgetreten sind.

Hinweis zur Umgebung: Die Beispiele unten nutzen ein anonymisiertes Homelab. Der Paperless-Host heißt doc-vader, der Keycloak-/Step-CA-Host auth-yoda, der Verzeichnisdienst dc-obiwan, die interne Domain darkside.local, der Realm rebellion.


Abschnitt 1 — HTTPS ohne Port

Das Ziel

Paperless soll intern unter zwei Namen erreichbar sein:

  • https://paperless.darkside.local — der vollständige Name, ohne Port
  • https://paperless — der Kurzname, der per 301 auf den FQDN umleitet

Kein Port 8091 mehr in der Adresszeile, keine WAN-Exposition. Die Instanz bleibt physisch vom Internet getrennt.

Die Architektur-Entscheidung

Der Reflex ist, den zentralen Reverse Proxy zu nehmen, der ohnehin im Netz steht. Das ist hier bewusst nicht passiert. Entscheidend war das Argument: ein lokaler Reverse Proxy direkt auf dem Paperless-Host trennt die Instanz physisch vom Internet, statt sie über eine IP-Allowlist „irgendwie" zu schützen. Dazu kommt: kein DNS-Cutover, kein Blast-Radius auf die vielen anderen Domains, die über den zentralen Proxy laufen.

Die Wahl fiel auf den Nginx Proxy Manager (NPM) als Docker-Container, statt rohem nginx. Grund: das ist das Standard-Muster in der übrigen Infrastruktur, und Zertifikate sowie Hosts lassen sich darüber sauber verwalten. Das TLS-Zertifikat kommt aus der internen Step-CA.

Was du brauchst

  • Den Paperless-Host (hier doc-vader, intern 192.168.66.70) mit laufendem Docker und Paperless auf Port 8091
  • Einen zweiten internen Host mit Step-CA (auth-yoda) zur Zertifikatsausstellung
  • Einen DNS-Eintrag für paperless.darkside.local auf den Paperless-Host
  • Die Root-CA der Step-CA, um sie auf den Clients zu installieren

Schritt 1: Nginx Proxy Manager aufsetzen

NPM läuft als eigener Docker-Container in einem eigenen Verzeichnis auf doc-vader. Zwei Dinge sind hier wichtig:

Erstens: Deinstalliere rohes nginx, falls es herumliegt. Ein hängengebliebener apt install nginx blockiert die Ports 80/443 und damit NPM. In der Praxis reichte apt purge.

Zweitens: NPM in Version 2.15.1 bringt keinen Default-Login mehr mit. Du kommst also nicht über das Standard-Passwort rein, sondern legst die erste Admin-Instanz über den unauthentifizierten Endpunkt POST /api/users an:

# Ersten Admin anlegen — NPM 2.15.1 hat keinen Default-Login mehr.
# Admin-UI ist nur intern erreichbar (kein WAN).
# Passwort NICHT in die Kommandozeile (Shell-History/ps) — besser per stdin/Env-Datei.
# Port 81 ist bis hier ein unauthentifizierter Admin-Create: nur aus dem LAN erreichbar
# lassen und nach dem ersten Login wieder schließen.
curl -X POST http://192.168.66.70:81/api/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"Admin","nickname":"labadmin","email":"admin@darkside.local","roles":["admin"],"password":"<dein-passwort>"}'

Danach meldest du dich an der Weboberfläche an (http://192.168.66.70:81) und legst deine Hosts an.

Erwartete Ausgabe: POST /api/users antwortet mit HTTP 201 und dem angelegten Benutzer-Objekt. Ein anschließender Login an der UI ist möglich.

Schritt 2: Zertifikat aus der Step-CA

Das Zertifikat wird nicht über Let’s Encrypt geholt (die Instanz ist nicht öffentlich), sondern aus der internen Step-CA. Wichtig sind die Subject Alternative Names (SAN = alternative Namen, unter denen das Zertifikat gilt). Das Zertifikat muss auf allen drei Wegen passen, über die du die Instanz ansprichst:

  • paperless.darkside.local (FQDN)
  • paperless (Kurzname)
  • 192.168.66.70 (IP, für den Fall, dass dich etwas direkt anspricht)

Gültigkeit: 1 Jahr. Ausgestellt wird über den internen Provisioner (rebellion-admin).

# Auf auth-yoda: Zertifikat für alle drei Namen ausstellen.
# SANs: FQDN + Kurzname + IP, Laufzeit 1 Jahr.
step ca certificate paperless.darkside.local paperless.crt paperless.key \
  --san paperless.darkside.local \
  --san paperless \
  --san 192.168.66.70 \
  --not-after 8760h \
  --provisioner rebellion-admin

Das Zertifikat samt Key lädst du anschließend im NPM unter SSL Certificates → Add → Custom Certificate hoch. Kein ACME, kein automatisches Renewal — die Erneuerung machst du jährlich von Hand oder per Job.

Erwartete Ausgabe: step ca certificate schreibt paperless.crt und paperless.key. Der NPM akzeptiert das Paar als Custom Certificate ohne Fehler.

Schritt 3: Proxy-Host für den FQDN

Im NPM legst du einen Proxy Host an:

FeldWert
Domain Namespaperless.darkside.local
Schemehttp
Forward Hostname / IP192.168.66.70
Forward Port8091
SSL Certificatedas Step-CA-Zertifikat aus Schritt 2

Das Backend bleibt bewusst auf HTTP auf 8091 — NPM terminiert TLS und leitet intern im Klartext weiter. Genau deshalb muss Paperless später über die Proxy-Header erfahren, dass die ursprüngliche Anfrage HTTPS war (Schritt 6).

Schritt 4: Redirection-Host für den Kurznamen

Für den Kurznamen paperless legst du einen Redirection Host an, der per 301 auf den FQDN umleitet:

paperless  →  301  →  https://paperless.darkside.local

Bewusst kein zweiter gleichwertiger Origin. Würdest du beide Namen als gleichwertige Proxy-Hosts betreiben, bräuchtest du in Keycloak zwei gültige Redirect-URIs und in Paperless zwei gültige CSRF-Origins — und handelst dir doppelte Fehlerquellen ein. Ein 301-Redirect ist die robustere Lösung.

Schritt 5: Keycloak-Redirect-URI ergänzen

Damit das spätere SSO (Abschnitt 2) über HTTPS funktioniert, trägst du im Keycloak-Client paperless die neue Redirect-URI ein:

https://paperless.darkside.local/accounts/oidc/keycloak/login/callback/

Die alte URI mit http://…:8091/… bleibt bewusst registriert und dient als Rollback-Pfad. Beide URIs gleichzeitig zu haben kostet nichts und hält den Rückweg offen.

Schritt 6: Paperless auf HTTPS umstellen

Jetzt teilst du Paperless mit, dass es hinter einem TLS-Proxy läuft. In der docker-compose.env bzw. den environment-Werten des webserver-Containers kommen die HTTPS-relevanten Variablen dazu:

# docker-compose.env auf doc-vader (Auszug, HTTPS-Umstellung)
PAPERLESS_URL=https://paperless.darkside.local
PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=https

# Proxy-Header: Paperless muss erfahren, dass TLS vor ihm terminiert wurde
PAPERLESS_PROXY_SSL_HEADER=<Platzhalter: Wert aus deiner laufenden Django-Env übernehmen>
PAPERLESS_TRUSTED_PROXIES=<IP des Proxy, wie Paperless sie im Socket sieht>

# CSRF muss den öffentlichen HTTPS-Origin kennen
PAPERLESS_CSRF_TRUSTED_ORIGINS=https://paperless.darkside.local

Drei Dinge dazu:

  1. PAPERLESS_URL ist der absolute URL, den Paperless nach außen verwendet — er muss auf den FQDN zeigen, nicht auf IP:Port.
  2. PAPERLESS_PROXY_SSL_HEADER sagt Paperless, dass es dem X-Forwarded-Proto-Header des Proxys vertrauen soll. Ohne das hält sich Paperless weiter für HTTP und baut falsche Redirect- und Callback-URLs.
  3. PAPERLESS_TRUSTED_PROXIES muss die Adresse des NPM enthalten — sonst ignoriert Paperless die Proxy-Header aus Sicherheitsgründen. Achtung: Läuft NPM als Container auf demselben Host, sieht Paperless nicht die LAN-IP des Hosts, sondern die Bridge-/Container-IP des Proxys. Die falsche IP führt zum selben Symptom (Invalid redirect_uri) wie ein fehlender Header — prüf per docker exec, welche Quell-IP an Paperless ankommt.

Wichtig: Die beiden <>-Werte sind bewusst Platzhalter. Übernimm sie 1:1 aus deiner laufenden Instanz (bzw. der Doku deiner Paperless-Version) — nicht blind aus irgendeinem Blog kopieren.

Vorher ein Backup der Compose-Dateien anlegen (z. B. .bak-<datum>), dann den Webserver neu erzeugen:

docker compose up -d --no-deps webserver

Wichtig: up -d und nicht restart — nur up -d liest die geänderte Env-Datei neu ein, ein restart tut das nicht.

Schritt 7: Root-CA auf die Clients

Das Step-CA-Zertifikat gilt nur, wenn die Root-CA der Step-CA auf jedem Client als vertrauenswürdig installiert ist. Sonst bekommst du im Browser eine Zertifikatswarnung, obwohl die Kette technisch sauber ist.

Exportiere die Root-CA auf dem Step-CA-Host und installiere sie auf deinen Geräten. Das genaue Verfahren hängt von der Distribution ab — auf einem Debian/Ubuntu-Client läuft es über apt und update-ca-certificates, auf einem Arch-basierten Client über pacman plus trust extract-compat. Der Feuerteufel (Firefox) übernimmt den System-Trust-Store automatisch; es war kein separater enterprise_roots-Schalter nötig.

Erwartete Ausgabe: curl -v https://paperless.darkside.local zeigt keinen Zertifikatsfehler mehr und arbeitet ohne --insecure.

Verifikation

PrüfungErwartetes Ergebnis
TLS-Kette gegen die Step-CA-Rootvalide, ohne --insecure
https://paperless.darkside.localHTTP 302 auf die Login-Seite, dann 200
https://paperless (Kurzname)301 → FQDN → 200
Post-Consume-Hookexit 0
Container-Statushealthy

Fallstricke — Das Perfide:

Das Perfide: Der Abbruch des apt install nginx war nur die Anzeige. Der Installationsjob lief im Hintergrund trotzdem zu Ende und belegte die Ports 80 und 443 — NPM kam nicht hoch, obwohl „nichts installiert" aussah. Erst apt purge gab die Ports frei.

Das Perfide: restart liest die Env-Datei nicht neu. Wer nach dem Setzen von PAPERLESS_URL nur docker compose restart webserver macht, sieht keine Änderung und sucht den Fehler in der falschen Ecke. Es muss up -d sein.

Das Perfide (Netzwerk-Split): Beim Recreate landete der frisch erzeugte webserver-Container in einem anderen Docker-Netz als die bereits laufenden Container (broker, db). Ergebnis: Error -5 connecting to broker:6379. No address associated with hostname. Der Fix ist ein Umhängen per docker network connect / docker network disconnect plus docker restart — kein Full-Stack-Recreate. Das ist wichtig, weil ein kompletter Recreate Container-interne Patches verliert (siehe unten).

Das Perfide (verlorene Patches): Ein manuell im Container angewandter Patch (ein bulk=100 in der Mail-Komponente) ist nach jedem Recreate weg und muss erneut angewendet werden. Solche Patches gehören, wenn möglich, ins Image — sonst in die Checkliste jedes Recreate.

Das Perfide (Shell-Escaping): Ein Keycloak-Client-Secret mit Sonderzeichen scheitert bei curl -d, weil die Shell die Zeichen interpretiert. curl --data-urlencode umgeht das.

Lessons Learned (HTTPS)

  • Ein lokaler Reverse Proxy ist eine Architektur-Entscheidung, kein Provisorium. Physische Trennung schlägt IP-Allowlist.
  • Der Kurzname gehört auf einen 301, nicht auf einen zweiten Origin. Das spart doppelte Redirect-URIs und doppelte CSRF-Origins.
  • SANs müssen FQDN, Kurzname und IP abdecken. Sonst funktioniert einer der drei Zugriffswege nicht.
  • up -d, nicht restart — sonst bleibt die Env-Änderung wirkungslos.
  • Container-Patches sind flüchtig. Jeder Recreate setzt sie zurück.

Abschnitt 2 — SSO über Keycloak mit dem Connect-Flow

Das Ziel

Ein bestehender lokaler Superuser (hier c3po, id=3, 909 Dokumente) soll sich zusätzlich über den zentralen Verzeichnisdienst anmelden können — konkret als leia.organa. Ohne Datenverlust, ohne Duplikat-User, ohne Lockout. Das lokale Passwort bleibt als Fallback aktiv.

Der falsche Weg — E-Mail-Matching (Das Perfide)

Der naheliegende Plan: einfach die E-Mail-Adresse des lokalen Users auf die des Verzeichnis-Users ändern, dann verbindet die Anmeldung beide automatisch. In diesem Fall hieße das, c3po@darkside.local auf leia.organa@darkside.local umzustellen.

Das funktioniert nicht. Zwei Gründe, die während der Umsetzung gegen den allauth-Quellcode geprüft wurden:

  1. allauth matcht nicht über E-Mail, sondern ausschließlich über SocialAccount(provider, uid). Der uid ist die eindeutige ID des Users beim Identity Provider — im OIDC-Fall eine UUID. Ohne einen passenden SocialAccount-Eintrag findet allauth nichts, egal wie die E-Mail lautet.
  2. Der E-Mail-Auth-Pfad ist sogar gefährlich. Hätte man ihn erzwungen, würde allauth beim vermeintlichen Match das lokale Passwort löschen (wipe_password()). Der lokale Fallback wäre in genau dem Moment weg, in dem man ihn braucht.

Das Perfide: Beide Fehlerbilder sehen von außen harmlos aus — „E-Mail passt doch". Der eine führt zu einem stillen Nicht-Match, der andere zu einem Passwortverlust. Die Lösung ist deshalb nicht das E-Mail-Matching, sondern der Connect-Flow: Der bereits eingeloggte User verbindet sein bestehendes Konto explizit mit dem Provider. Kein Signup, kein Auto-Linking, kein Passwortverlust.

Schritt 1: Keycloak-Client anlegen

Im Realm (rebellion) legst du einen Client für Paperless an:

  • Client-ID: paperless
  • Access Type: Confidential (es gibt ein Secret)
  • Standard Flow: aktiviert
  • Redirect-URI: die HTTPS-URI aus Abschnitt 1, plus die alte HTTP-URI als Rollback
https://paperless.darkside.local/accounts/oidc/keycloak/login/callback/
http://paperless.darkside.local:8091/accounts/oidc/keycloak/login/callback/

Schritt 2: Paperless konfigurieren

In der docker-compose.env auf doc-vader kommen die SSO-Variablen dazu. Die Provider-Registrierung trägst du mit der OIDC-Konfiguration deines Keycloak-Clients ein (Issuer, Client-ID, Client-Secret, PKCE aktiviert):

# docker-compose.env auf doc-vader (Auszug, SSO-Anbindung)
# Provider-Registrierung (Werte je nach OIDC-Provider/Client) inkl. OAUTH_PKCE_ENABLED
PAPERLESS_APPS=<allauth-OIDC-Provider>
PAPERLESS_SOCIALACCOUNT_PROVIDERS=<Provider-Konfiguration, enthält OAUTH_PKCE_ENABLED>

# Kein automatisches Anlegen und kein Auto-Linking von Accounts
PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=false
PAPERLESS_SOCIAL_AUTO_SIGNUP=false

# URL und Schema passend zur HTTPS-Instanz (siehe Abschnitt 1)
PAPERLESS_URL=https://paperless.darkside.local
PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=https

Im docker-compose.yml ergänzt du außerdem einen extra_hosts-Eintrag, damit der Container den öffentlichen Keycloak-Namen auflösen kann, ohne auf öffentliches DNS angewiesen zu sein:

# docker-compose.yml auf doc-vader (Auszug)
services:
  webserver:
    extra_hosts:
      - "auth-yoda.darkside.local:10.99.99.29"

Die beiden Variablen PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=false und PAPERLESS_SOCIAL_AUTO_SIGNUP=false sind hier keine Deko: Sie verhindern, dass sich irgendjemand über den Provider automatisch ein Konto anlegt. Die Verknüpfung soll ausschließlich über den bewussten Connect-Flow passieren.

Achtung, Hostname-Mismatch: Der Keycloak-Issuer ist an einen festen Hostnamen gebunden. Der OIDC-Issuer lautet in diesem Setup https://auth-yoda.darkside.local/realms/rebellion, egal über welchen Host du das well-known-Endpoint abfragst. Wenn du diesen Namen falsch setzt, scheitert die Discovery.

Schritt 3: Container neu erzeugen

Wie in Abschnitt 1: docker compose up -d --no-deps webserver (nicht restart). Danach prüfen, ob der Netzwerk-Split wieder zugeschlagen hat (Abschnitt 1) — und den bulk=100-Patch erneut anwenden.

Schritt 4: Den Connect-Flow durchführen

Die Verknüpfung passiert eingeloggt mit dem lokalen Passwort:

  1. Mit dem lokalen Konto (c3po) über das lokale Passwort anmelden.
  2. Oben rechts auf den Avatar → My Profile.
  3. Abschnitt Social account providers → SSO → Connect.

Falls der Button in der UI fehlt, geht es auch direkt über die URL:

https://paperless.darkside.local/accounts/oidc/keycloak/login/?process=connect

Der Connect-Flow verknüpft das bestehende Konto mit dem Provider-Account. Es wird kein neuer User angelegt und kein Passwort angefasst.

Verifikation (Closed-Loop)

Die Verifikation läuft gegen die Datenbank, mit einem Snapshot vorher und nachher. Erwartet wird ein einzelner neuer SocialAccount-Eintrag, der auf den bestehenden User zeigt:

  • SocialAccount mit user_id=3, provider=keycloak und der uid (UUID) des Verzeichnis-Users
  • Die uid entspricht exakt dem leia.organa-Account
  • Kein Duplikat-User — die Gesamtzahl der User bleibt bei 3
  • is_superuser=True unverändert
  • Das lokale Passwort funktioniert weiterhin (Fallback intakt)
  • 909 Dokumente unverändert

Nur wenn alle sechs Punkte stimmen, ist die Anbindung sauber. Der entscheidende Test ist der zweite: kein zusätzlicher User.

Fallstricke — Das Perfide:

Das Perfide: E-Mail-Matching wirkt logisch und ist trotzdem falsch. allauth kennt nur SocialAccount(provider, uid), und der E-Mail-Pfad löscht beim Match das lokale Passwort. Wer hier auf „das passt schon" setzt, verliert den Fallback genau dann, wenn er ihn braucht.

Das Perfide: PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL muss zum tatsächlich genutzten Schema passen. Läuft Paperless über HTTP, du setzt aber https, lehnt Keycloak den Callback mit Invalid redirect_uri ab — und der Fehler sieht aus wie ein falsch konfigurierter Redirect, ist aber ein Schema-Mismatch. Nach dem HTTPS-Umbau (Abschnitt 1) steht der Wert korrekt auf https, davor war er explizit http.

Das Perfide: Auto-Signup. Ohne die beiden false-Werte kann sich praktisch jeder mit einem gültigen Provider-Account ein Paperless-Konto anlegen. Der Connect-Flow macht Auto-Signup überflüssig.

Das Perfide: Ein Secret mit Sonderzeichen scheitert im curl -d-Aufruf an der Shell. --data-urlencode löst es.

Lessons Learned (SSO)

  • Verlasse dich nie auf E-Mail-Matching in allauth. Der Match läuft über (provider, uid), niemals über die E-Mail.
  • Der E-Mail-Auth-Pfad ist ein Passwort-Killer (wipe_password()).
  • Der Connect-Flow ist der sichere Weg: bewusst, eingeloggt, ohne Signup und ohne Passwortänderung.
  • Closed-Loop heißt hier: User-Zahl vorher/nachher vergleichen. Ein SSO-Login, der funktioniert, aber einen Duplikat-User anlegt, ist ein Fehlschlag.
  • Provider-Konfiguration gegen den Quellcode prüfen, nicht gegen den Bauch. Bei Bibliotheken wie allauth ist die Doku oft unvollständig.

Abschnitt 3 — OCR stabil ohne tmpfs-Explosion

Diese Baustelle hat einen eigenen Incident-Post, weil sie sich als tief genug erwies: Paperless-ngx OCR sprengt das tmpfs. Hier die Kurzfassung als Teil des Produktiv-Setups.

Das Problem

Drei Scanner-Scans kommen per Mail rein (48 + 14 + 34 Seiten, zusammen 81 MB PDF). Alle drei crashen beim Import, reproduzierbar, mitten in der OCR-Verarbeitung, ohne verwertbare Fehlermeldung.

Der Container-/tmp ist ein tmpfs mit 512 MB — das Standard-Setup. Der eigentliche Verbraucher: unpaper, der Vorverarbeitungsschritt von OCRmyPDF (Deskew/Clean). unpaper arbeitet nicht auf dem komprimierten PDF, sondern konvertiert jede Seite in ein unkomprimiertes PPM-Bitmap und legt das in /tmp ab. Bei ~26 MB pro Seite sind das schon für einen 48-Seiten-Scan rund 1,2 GB Temp — gegen ein 512-MB-Limit. Der Worker stirbt mitten im Job.

Schritt 1: Diagnose

# Logs: oft kein klarer Fehler, nur ein abgebrochener Worker
docker logs paperless_webserver --since 1h | grep -i error

# Wichtig: die Container-Disk während des laufenden Imports beobachten
docker exec paperless_webserver df -h /tmp

Bad — während des Imports kurz vor dem Crash:

Filesystem      Size  Used Avail Use% Mounted on
tmpfs           512M  510M     0 100% /tmp

Zusätzlich: Die Host-Disk stand unabhängig davon bei 100 %. Grund waren 7 GB verwaiste ocrmypdf.io.*-Temp-Verzeichnisse aus früheren, abgebrochenen OCR-Läufen, die sich nie aufgeräumt hatten.

du -sh /usr/src/paperless/scratch/ocrmypdf.io.* 2>/dev/null | sort -rh | head

Schritt 2: Die Ursache verstehen

Das Perfide: Die Variable, die jede Paperless-Doku zuerst nennt, wenn es um OCR-Temp geht — PAPERLESS_OCR_TMPDIR — existiert in der laufenden Version nicht. Sie wird gesetzt, akzeptiert und dann ignoriert. Ein stiller No-Op. Du denkst, das Problem sei behoben; der nächste Crash beweist das Gegenteil.

Das Perfide: Das tmpfs einfach größer ziehen, z. B. auf 2 GB, funktioniert nicht, wenn der Container-Cgroup selbst bei 2 GB gedeckelt ist. tmpfs zählt gegen den RAM-Cgroup. Ein 2-GB-tmpfs in einem 2-GB-Cgroup bedeutet: Sobald unpaper tatsächlich viel Temp produziert, killt der OOM-Killer den Prozess. Mehr tmpfs verschiebt den Crash-Punkt nur — es löst nichts.

Die eigentliche Ursache: unpaper ist der Haupt-Temp-Verbraucher der gesamten OCR-Kette — nicht OCRmyPDF, nicht Tesseract.

Schritt 3: Die Env-Stellschrauben

Fünf Werte, zusammen in der docker-compose.env:

# docker-compose.env auf doc-vader (Auszug, OCR-Stabilität)

# Persistenten Scratch-Pfad statt tmpfs verwenden
TMPDIR=/usr/src/paperless/scratch

# unpaper deaktivieren — der Haupt-Temp-Verbraucher
PAPERLESS_OCR_CLEAN=none

# PDF-Optimierung (zusätzlicher Temp-Verbrauch) abschalten
PAPERLESS_OCR_USER_ARGS={"optimize": 0}

# Weniger parallele Threads = weniger gleichzeitiger Temp-Verbrauch
PAPERLESS_THREADS_PER_WORKER=4

# Großzügigeres Timeout für die nun langsameren, aber stabilen Jobs
PAPERLESS_WORKER_TIMEOUT=7200

Was passiert:

  • TMPDIR biegt den Temp-Pfad vom kleinen tmpfs auf einen persistenten Pfad auf der Container-Disk um (/usr/src/paperless/scratch) — begrenzt nur durch echten Plattenplatz.
  • PAPERLESS_OCR_CLEAN=none schaltet unpaper komplett ab und beseitigt damit die PPM-Temp-Flut. Der Trade-off: keine automatische Entrauschung/Begradigung mehr. Für saubere Scanner-Scans ein akzeptabler Tausch.
  • PAPERLESS_OCR_USER_ARGS={"optimize": 0} schaltet die PDF-Optimierung als zusätzlichen Temp-Verbraucher ab.
  • PAPERLESS_THREADS_PER_WORKER=4 reduziert die gleichzeitig erzeugten Temp-Daten.
  • PAPERLESS_WORKER_TIMEOUT=7200 (2 Stunden) gibt den jetzt etwas langsameren Jobs genug Luft.

Dann den Webserver neu erzeugen:

docker compose up -d --no-deps webserver

Schritt 4: Aufräumen und vorbeugen

# Einmalig: alte ocrmypdf-Reste löschen (geben die Host-Disk frei)
rm -rf /usr/src/paperless/scratch/ocrmypdf.io.*

# Cron: Temp-Reste älter als 24h regelmäßig wegräumen
# (crontab -e auf doc-vader)
0 3 * * * find /usr/src/paperless/scratch -maxdepth 1 -name 'ocrmypdf.io.*' -mtime +1 -exec rm -rf {} \;

Der Cleanup-Cron ist kein Luxus: Verwaiste Temp-Dateien aus gecrashten Jobs räumen sich nicht selbst auf und füllen sonst zuverlässig die Platte.

Verifikation

# Während eines neuen, großen Imports: tmpfs bleibt fast leer
docker exec paperless_webserver df -h /tmp

Good — nach dem Fix:

Filesystem      Size  Used Avail Use% Mounted on
tmpfs           512M   ~10M   ...   ~3% /tmp

/tmp bleibt nahezu leer, weil unpaper gar nicht mehr läuft; der verbleibende Temp von OCRmyPDF/Tesseract ist marginal. Zusätzlich prüfen: Container healthy, Import läuft durch, Host-Disk stabil.

Fallstricke — Das Perfide:

Das Perfide: PAPERLESS_OCR_TMPDIR ist ein dokumentierter Name ohne Funktion. Bevor du eine Variable als Fix einsetzt, verifiziere, dass sie tatsächlich etwas bewirkt — prüfe die Wirkung, nicht die Doku.

Das Perfide: „tmpfs größer machen" tötet den Job per OOM statt per vollem tmpfs. Beide Wege enden mit einem toten Worker.

Das Perfide: Der Temp-Fresser ist selten das Haupttool. Bei OCR-Speicherproblemen zuerst die Vorverarbeitung prüfen — hier unpaper.

Das Perfide: Verwaiste ocrmypdf.io.*-Reste füllen die Platte unabhängig vom tmpfs. Ohne Cron sammelt sich das bei jedem Crash neu an.

Lessons Learned (OCR)

  • Dokumentierte Env-Variablen sind nicht automatisch wirksame Env-Variablen. Wirkung prüfen, nicht Namen vertrauen.
  • tmpfs zählt gegen den RAM-Cgroup. Größer ziehen ist kein Fix, nur eine Verlagerung.
  • unpaper zuerst verdächtigen. Es produziert die großen unkomprimierten Zwischendateien.
  • Cleanup-Cron von Anfang an. Gecrashte OCR-Jobs hinterlassen Temp-Müll, der nie verschwindet.
  • Nach jedem Recreate: Container-Patches erneut anwenden.

Gesamt-Checkliste

HTTPS ohne Port

  • Rohes nginx entfernt (Ports 80/443 frei), NPM als Docker-Container
  • Erster NPM-Admin über POST /api/users angelegt (kein Default-Login in 2.15.1)
  • Step-CA-Zertifikat mit SANs FQDN + Kurzname + IP, 1 Jahr
  • Proxy-Host paperless.darkside.local → 192.168.66.70:8091
  • Redirection-Host Kurzname → 301 → FQDN
  • Keycloak-Redirect-URI (HTTPS) ergänzt, alte als Rollback registriert
  • PAPERLESS_URL, …_HTTP_PROTOCOL=https, PROXY_SSL_HEADER, TRUSTED_PROXIES, CSRF_TRUSTED_ORIGINS gesetzt
  • docker compose up -d (nicht restart)
  • Root-CA auf allen Clients installiert, TLS ohne --insecure valide

SSO über Keycloak

  • Keycloak-Client paperless, Confidential, Standard Flow, Realm rebellion
  • Provider-Konfiguration inkl. PKCE gesetzt
  • PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=false, PAPERLESS_SOCIAL_AUTO_SIGNUP=false
  • extra_hosts für den Keycloak-Namen
  • Kein E-Mail-Matching — Connect-Flow verwendet
  • Closed-Loop: SocialAccount(provider, uid) vorhanden, User-Zahl unverändert, Superuser-Flag unverändert, lokales Passwort funktioniert weiter

OCR-Stabilität

  • PAPERLESS_OCR_TMPDIR nicht als Fix verwenden (No-Op)
  • TMPDIR=/usr/src/paperless/scratch (persistent, nicht tmpfs)
  • PAPERLESS_OCR_CLEAN=none (unpaper aus)
  • PAPERLESS_OCR_USER_ARGS={"optimize": 0}
  • PAPERLESS_THREADS_PER_WORKER=4
  • PAPERLESS_WORKER_TIMEOUT=7200
  • Verwaiste ocrmypdf.io.* gelöscht, Cleanup-Cron eingerichtet
  • Nach dem Recreate: bulk=100-Patch erneut angewendet

Lessons Learned (übergreifend)

  1. Produktiv wird Paperless an den Rändern, nicht im Kern. Der Import funktioniert sofort — HTTPS, SSO und OCR-Stabilität sind die drei Stellen, an denen der Betrieb wirklich beginnt.
  2. Bibliotheken gegen den Quellcode prüfen. Sowohl das allauth-Matching als auch die nicht existente PAPERLESS_OCR_TMPDIR-Variable waren dokumentierte Erwartungen, die die Realität widerlegt hat.
  3. Closed-Loop verifizieren, nicht „geht doch". Ein SSO-Login, der einen Duplikat-User anlegt, ist kaputt. Ein OCR-Fix, der das tmpfs leert, aber die Ursache nicht kennt, ist Zufall.
  4. up -d statt restart, immer. Env-Änderungen wirken nur beim Neuerzeugen.
  5. Recreate ist der Moment, in dem die versteckten Fehler zuschlagen: Netzwerk-Split und verlorene Container-Patches. Beide in die Checkliste, beide nach jedem up -d prüfen.

🛒 Empfohlene Hardware

Paperless ist genügsam, aber OCR frisst beim Import kurzzeitig CPU und Disk-I/O. Ein sparsamer Mini-PC mit schneller SSD reicht für ein Homelab locker:


Einige Links auf dieser Seite sind Affiliate-Links. Wenn du über diese Links einkaufst, erhalte ich eine kleine Provision — für dich ändert sich am Preis nichts. So unterstützt du diesen Blog und ermöglicht weitere kostenlose Tutorials. Danke! 🙏