E7 · Paperless v3 Neustart — Umsetzungsplan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Paperless-ngx 3.1 als Greenfield-Stack auf dem NUC produktiv nehmen: Zulauf über den HP 4302 (SMB) und Proton Mail (Bridge), native KI gegen llama-swap auf dem aiserver, Ordnerlogik über Speicherpfade, nächtlicher Export, Consume-Überwachung.
Architecture: Ein Compose-Stack paperless-ngx/ mit sechs Containern (App, Postgres, Redis, Gotenberg, Tika,
Proton-Bridge) auf proxy_network + paperlessngx_internal. Dokumente auf /mnt/paperless (UNAS), Datenbank, Index
und Consume auf der SSD. Secrets über den Infisical-Agent. Host-Bausteine (Samba-Freigabe, systemd-Timer,
Textfile-Collector) liegen versioniert unter paperless-ngx/host-config/ und paperless-ngx/scripts/, Robin deployt
sie nach Runbook.
Tech Stack: Docker Compose, paperless-ngx 3.1.3, PostgreSQL 18.6, Redis 8.10.1, Gotenberg 8.37.0, Tika 3.3.1.0,
Proton Mail Bridge 3.26.0 (Image ghcr.io/enucatl/protonmail-bridge), Infisical Agent, Samba (nativ), systemd,
node-exporter Textfile-Collector, Prometheus v3.13.1, Python 3.11+ mit uv (requests 2.34.2, PyYAML 6.0.3,
pytest 9.1.1).
Spec: paperless-design.md (gleicher Ordner). Der Plan argumentiert aus der Spec; wer
ausführt, liest beide.
Global Constraints¶
- Kein NUC-Write durch Claude. Alle Schritte mit
[ROBIN/NUC]führt Robin aus (ssh nuc-rootfür/etc,/optundgit pull). Claude liefert die Befehle, prüft Ergebnisse, die Robin zurückmeldet. - Kein Zugriff von außen. Nur der Traefik-Router
paperless.home.robinwerner.net. - Keine Datenübernahme. Alte Verzeichnisse werden geleert (genau ein Testdokument).
/mnt/scannerbleibt unangetastet — weder verschieben noch kopieren.- UID/GID 977/988 für Paperless auf NUC, im Container (
USERMAP_UID/GID) und auf dem UNAS-Export. - Image-Pins:
paperless-ngx:3.1.3,postgres:18.6,redis:8.10.1,gotenberg:8.37.0,tika:3.3.1.0,protonmail-bridge:3.26.0. - Infisical: Projekt
c4915514-a815-4c32-a497-d669aa51963f, Envdev, Pfad/paperless-ngx, gerendert nach/run/infisical/paperless-ngx.env. - KI-Endpunkt:
http://10.10.10.7:9292/v1, Modellebrain-llamacpp(Vorschläge) undembed-llamacpp(Embeddings).PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=de-de(seit 22.09., vorher ungesetzt). - Repo-Konventionen: Doku deutsch mit Umlauten, Code/Commits/Variablen englisch, Conventional Commits, Feature-Branch
- PR (nie direkt auf
main),set -euo pipefail+shellcheck+shfmt -i 2für Shell,rufffür Python, Funktionen ≤ 100 Zeilen. - Aus der Session-Memory: Bash-Aufrufe kurz halten, Dateien einzeln stagen, Commit-Text als Datei
(
git commit -F).borgmatic list --archive latest --path mnt/…(nicht--find). Nachgit pullauf dem NUC Rechte prüfen:find /opt/rpi-docker-compose -type f ! -perm -o=r(erwartet: nurtraefik/.env,ts3/.env).
Dateistruktur¶
Repo homeserver (Deploy-Pfad auf dem NUC: /opt/rpi-docker-compose/):
| Datei | Verantwortung | Teil |
|---|---|---|
paperless-ngx/docker-compose.yaml |
Stack-Definition (neu geschrieben) | A |
paperless-ngx/paperless.env |
gelöscht (getrackter Secret-Key) | A |
paperless-ngx/docs.md |
Service-Doku (neu geschrieben) | A |
infrastructure/infisical-agent/agent.yaml.example |
Template-Block /paperless-ngx |
A |
CLAUDE.md |
Env-Datei-Liste, migrierte Stacks | A |
paperless-ngx/host-config/smb-paperless.conf |
Samba-Freigabe [paperless] (per include) |
B |
paperless-ngx/host-config/paperless-export.{service,timer} |
nächtlicher document_exporter |
B |
paperless-ngx/host-config/paperless-consume-collector.{service,timer} |
Consume-Stau-Metrik | B |
paperless-ngx/scripts/consume-stale-collector.sh |
schreibt paperless.prom |
B |
monitoring-stack/configs/prometheus/rules/paperless-alerts.yml |
Alarmregeln | B |
monitoring-stack/configs/prometheus/rules/container-alerts.yml |
paperless_ngx in ContainerDown |
B |
paperless-ngx/seed/schema.yaml |
Startschema (Tags, Typen, Pfade, Korrespondenten) | B |
paperless-ngx/seed/seed.py |
idempotenter Seed über die REST-API | B |
paperless-ngx/seed/tests/test_seed.py |
Tests für den Seed | B |
Repo homelab-documentation: docs/projects/homeserver/productivity/paperless-ngx.md (Teil F),
docs/planung/2026-08/umsetzungs-log.md (je Session).
NUC (Robin): /etc/infisical/agent.yaml, /etc/samba/smb.conf, Systembenutzer paperless,
/etc/systemd/system/paperless-*.{service,timer}, Datenverzeichnisse.
Zwei PRs im homeserver: PR-A (Teil A, Stack) und PR-B (Teil B, Host-Bausteine + Seed + Monitoring). Beide
zweigen von origin/main ab und berühren disjunkte Dateien.
Teil A — Repo: Stack-Neubau (Branch feat/paperless-v3-e7)¶
Task A1: Arbeits-Branch¶
Files: keine
- Step 1: Branch von
origin/main
- Step 2: Basis prüfen
Run: git log --oneline -1 && git status --short && ls paperless-ngx/
Expected: HEAD = aktueller origin/main, keine Änderungen, Verzeichnis enthält docker-compose.yaml docs.md paperless.env
Task A2: Infisical-Template-Block¶
Files:
- Modify: infrastructure/infisical-agent/agent.yaml.example
Interfaces:
- Produces: gerenderte Datei /run/infisical/paperless-ngx.env mit den Keys PAPERLESS_SECRET_KEY,
POSTGRES_PASSWORD, PAPERLESS_DBPASS (Task A3 bindet sie per env_file ein, Task C1 legt die Werte an)
- Step 1: Block einfügen
Vor der abschließenden Kommentarzeile # Pro neuem Stack einen weiteren Block anhängen, dann in der templates:-Liste
ergänzen (gleiche Einrückung wie der speedtest-tracker-Block):
- destination-path: /run/infisical/paperless-ngx.env
template-content: |
{{- with secret "c4915514-a815-4c32-a497-d669aa51963f" "dev" "/paperless-ngx" }}
{{- range . }}
{{ .Key }}={{ .Value }}
{{- end }}
{{- end }}
config:
polling-interval: 60s
- Step 2: Lint
Run: yamllint -d relaxed infrastructure/infisical-agent/agent.yaml.example
Expected: keine error-Zeilen (Warnungen zur Zeilenlänge sind vorbestehend)
- Step 3: Commit
git add infrastructure/infisical-agent/agent.yaml.example
git commit -m "feat(infisical): render secrets for the paperless-ngx stack"
Task A3: docker-compose.yaml neu schreiben¶
Files:
- Modify: paperless-ngx/docker-compose.yaml (vollständig ersetzen)
Interfaces:
- Consumes: /run/infisical/paperless-ngx.env (Task A2/C1), Host-Verzeichnisse aus Task C2
- Produces: Container paperless_ngx, paperless_db, paperless_redis, gotenberg, tika, protonmail-bridge;
Profil init mit protonmail-bridge-init (Task E2); Consume-Pfad /mnt/ssd/container-data/paperless/consume
(Task B2 hängt die Freigabe an …/consume/robin)
- Step 1: Datei mit folgendem Inhalt überschreiben
# Paperless-ngx 3.1 — E7 Greenfield (Design: homelab-documentation/docs/planung/2026-09-e7/).
#
# Secrets (PAPERLESS_SECRET_KEY, POSTGRES_PASSWORD, PAPERLESS_DBPASS) kommen aus
# /run/infisical/paperless-ngx.env (infisical-agent). Alles andere steht hier.
networks:
proxy_network:
external: true
paperlessngx_internal:
driver: bridge
name: paperlessngx_internal
services:
paperless_ngx:
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.3
container_name: paperless_ngx
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy_network
- paperlessngx_internal
depends_on:
paperless_db:
condition: service_healthy
paperless_redis:
condition: service_healthy
gotenberg:
condition: service_started
tika:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
volumes:
- /mnt/ssd/container-data/paperless/data:/usr/src/paperless/data
- /mnt/ssd/container-data/paperless/consume:/usr/src/paperless/consume
- /mnt/paperless/media:/usr/src/paperless/media
- /mnt/paperless/export:/usr/src/paperless/export
- /mnt/paperless/trash:/usr/src/paperless/trash
env_file:
- /run/infisical/paperless-ngx.env
environment:
# --- Core ---
PAPERLESS_URL: "https://paperless.home.robinwerner.net"
PAPERLESS_APP_TITLE: "Paperless"
PAPERLESS_TIME_ZONE: "Europe/Berlin"
USERMAP_UID: "977"
USERMAP_GID: "988"
# --- Redis / Datenbank (v3: DBENGINE ist Pflicht) ---
PAPERLESS_REDIS: "redis://paperless_redis:6379"
PAPERLESS_DBENGINE: "postgresql"
PAPERLESS_DBHOST: "paperless_db"
PAPERLESS_DBPORT: "5432"
PAPERLESS_DBNAME: "paperless"
PAPERLESS_DBUSER: "paperless"
PAPERLESS_DB_OPTIONS: "connect_timeout=30"
PAPERLESS_DB_READ_CACHE_ENABLED: "true"
# --- Tika / Gotenberg (Mail als PDF, Office-Anhänge) ---
PAPERLESS_TIKA_ENABLED: "1"
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
# --- OCR (v3: 'skip' entfällt; Archiv immer, damit documents/archive vollständig ist) ---
PAPERLESS_OCR_LANGUAGE: "deu+eng"
PAPERLESS_OCR_MODE: "auto"
PAPERLESS_ARCHIVE_FILE_GENERATION: "always"
PAPERLESS_OCR_CLEAN: "clean"
PAPERLESS_OCR_DESKEW: "true"
PAPERLESS_OCR_ROTATE_PAGES: "true"
PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD: "12"
PAPERLESS_OCR_OUTPUT_TYPE: "pdfa"
# --- Performance (NUC i5-1135G7, 4C/8T, teilt sich den Host mit ~60 Containern) ---
PAPERLESS_TASK_WORKERS: "2"
PAPERLESS_THREADS_PER_WORKER: "2"
PAPERLESS_WEBSERVER_WORKERS: "2"
PAPERLESS_CONVERT_MEMORY_LIMIT: "512"
PAPERLESS_CONVERT_TMPDIR: "/tmp/paperless"
# --- Consumer: consume/<eigentuemer>/ ; Unterordner ist KEIN Tag ---
PAPERLESS_CONSUMER_RECURSIVE: "true"
PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS: "false"
PAPERLESS_CONSUMER_DELETE_DUPLICATES: "true"
PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED: "false"
# --- Barcodes: ASN setzen UND an jedem ASN-Etikett ein neues Dokument beginnen ---
PAPERLESS_CONSUMER_ENABLE_BARCODES: "true"
PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE: "true"
PAPERLESS_CONSUMER_ASN_BARCODE_PREFIX: "ASN"
PAPERLESS_CONSUMER_BARCODE_MAX_PAGES: "0"
# --- Dateinamen: Auffang, wenn kein Speicherpfad gesetzt ist (Spec Abschnitt 5) ---
PAPERLESS_FILENAME_FORMAT: >-
{% if owner_username %}{{ owner_username }}/{% endif %}_Unsortiert/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}
PAPERLESS_FILENAME_DATE_ORDER: "DMY"
PAPERLESS_DATE_ORDER: "DMY"
PAPERLESS_TRASH_DIR: "/usr/src/paperless/trash"
PAPERLESS_EMPTY_TRASH_DELAY: "30"
# --- Sicherheit (v3: TRUSTED_PROXIES gegen Login-403 hinter Traefik; Präfix-Match, kein CIDR) ---
PAPERLESS_ALLOWED_HOSTS: "paperless.home.robinwerner.net,localhost,127.0.0.1"
PAPERLESS_CSRF_TRUSTED_ORIGINS: "https://paperless.home.robinwerner.net"
PAPERLESS_TRUSTED_PROXIES: "10.100."
PAPERLESS_COOKIE_PREFIX: "paperless_"
PAPERLESS_ACCOUNT_SESSION_REMEMBER: "true"
PAPERLESS_SESSION_COOKIE_AGE: "2592000"
PAPERLESS_AUDIT_LOG_ENABLED: "true"
# --- KI (native v3, openai-like gegen llama-swap auf dem aiserver) ---
PAPERLESS_AI_ENABLED: "true"
PAPERLESS_AI_LLM_BACKEND: "openai-like"
PAPERLESS_AI_LLM_ENDPOINT: "http://10.10.10.7:9292/v1"
PAPERLESS_AI_LLM_MODEL: "brain-llamacpp"
PAPERLESS_AI_LLM_API_KEY: "none"
PAPERLESS_AI_LLM_CONTEXT_SIZE: "16384"
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "embed-llamacpp"
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
PAPERLESS_LLM_INDEX_TASK_CRON: "30 3 * * *"
labels:
- "traefik.enable=true"
- "traefik.http.routers.paperless.rule=Host(`paperless.home.robinwerner.net`)"
- "traefik.http.routers.paperless.entrypoints=websecure"
- "traefik.http.routers.paperless.tls.certresolver=letsencrypt"
- "traefik.http.services.paperless.loadbalancer.server.port=8000"
paperless_redis:
image: docker.io/library/redis:8.10.1
container_name: paperless_redis
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- paperlessngx_internal
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
- /mnt/ssd/container-data/paperless/redis:/data
paperless_db:
image: docker.io/library/postgres:18.6
container_name: paperless_db
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- paperlessngx_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
volumes:
- /mnt/ssd/container-data/paperless/db:/var/lib/postgresql
# POSTGRES_PASSWORD kommt aus Infisical und greift NUR bei leerem Datadir.
env_file:
- /run/infisical/paperless-ngx.env
environment:
POSTGRES_DB: "paperless"
POSTGRES_USER: "paperless"
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
command:
- "postgres"
- "-c"
- "shared_buffers=256MB"
- "-c"
- "effective_cache_size=512MB"
- "-c"
- "work_mem=16MB"
- "-c"
- "maintenance_work_mem=128MB"
- "-c"
- "random_page_cost=1.1"
gotenberg:
image: docker.io/gotenberg/gotenberg:8.37.0
container_name: gotenberg
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- paperlessngx_internal
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:3.3.1.0
container_name: tika
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- paperlessngx_internal
# Proton Mail Bridge, headless. Nur im internen Netz, keine Ports nach außen:
# Paperless spricht IMAP auf protonmail-bridge:1243 (socat -> 127.0.0.1:1143).
# Image läuft als Benutzer protonmail (UID 1000), Daten unter /data.
# Einmalige Anmeldung (2FA) über das Profil "init", siehe docs.md.
protonmail-bridge:
image: ghcr.io/enucatl/protonmail-bridge:3.26.0
container_name: protonmail-bridge
restart: unless-stopped
command: ["run"]
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
- paperlessngx_internal
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,mode=0700,uid=1000,gid=1000
volumes:
- /mnt/ssd/container-data/paperless/protonmail-bridge:/data
protonmail-bridge-init:
image: ghcr.io/enucatl/protonmail-bridge:3.26.0
container_name: protonmail-bridge-init
command: ["init"]
profiles:
- init
stdin_open: true
tty: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
- paperlessngx_internal
volumes:
- /mnt/ssd/container-data/paperless/protonmail-bridge:/data
- Step 2: Keine Interpolation, kein
paperless.envmehr
Run: grep -nE '\$\{|paperless\.env|:-paperless' paperless-ngx/docker-compose.yaml || echo CLEAN
Expected: CLEAN
- Step 3: YAML-Lint
Run: yamllint -d relaxed paperless-ngx/docker-compose.yaml
Expected: keine error-Zeilen
- Step 4: Compose-Syntax lokal prüfen (die
env_fileexistiert nur auf dem NUC, deshalb per Stdin auf eine leere Datei umgebogen)
touch /tmp/paperless-ngx.env
sed 's#/run/infisical/paperless-ngx.env#/tmp/paperless-ngx.env#' paperless-ngx/docker-compose.yaml \
| docker compose --project-directory paperless-ngx -f - config --quiet && echo CONFIG_OK
Expected: CONFIG_OK (Warnung „network proxy_network declared as external" ist lokal normal)
- Step 5: Commit
git add paperless-ngx/docker-compose.yaml
git commit -m "feat(paperless): rebuild stack on paperless-ngx 3.1 with native AI and Proton Bridge"
Task A4: Geleakte paperless.env entfernen, CLAUDE.md nachziehen¶
Files:
- Delete: paperless-ngx/paperless.env
- Modify: CLAUDE.md
- Step 1: Datei aus Git und Working-Tree entfernen
git rm paperless-ngx/paperless.env
test -e paperless-ngx/paperless.env && echo STILL_THERE || echo REMOVED
Expected: REMOVED
-
Step 2:
CLAUDE.md, Abschnitt „Required Environment Files": die Zeile- `paperless-ngx/docker-compose.env`: Paperless-NGX settingslöschen. -
Step 3:
CLAUDE.md, Abschnitt „Security Considerations":- Migrated stacks (`ollama`, `speedtest-tracker`) use the Infisical Agent→- Migrated stacks (`ollama`, `speedtest-tracker`, `paperless-ngx`) use the Infisical Agent -
Step 4: Prüfen
Run: grep -n 'docker-compose.env\|paperless-ngx)' CLAUDE.mdExpected: genau eine Trefferzeile (die neue Liste der migrierten Stacks), keine mitdocker-compose.env`
- Step 5: Commit
git add CLAUDE.md paperless-ngx/paperless.env
git commit -m "chore(paperless): drop tracked env file with leaked secret key"
Task A5: paperless-ngx/docs.md neu schreiben¶
Files:
- Modify: paperless-ngx/docs.md (vollständig ersetzen)
- Step 1: Inhalt
# Paperless-ngx
Dokumentenmanagement mit OCR, Volltextsuche, ASN-Barcodes und nativer KI. [GitHub](https://github.com/paperless-ngx/paperless-ngx)
Design und Umsetzungsplan (E7): `homelab-documentation/docs/planung/2026-09-e7/`.
## Zugriff
| | |
|---|---|
| URL | `https://paperless.home.robinwerner.net` (nur intern) |
| Netzwerk | `proxy_network`, `paperlessngx_internal` |
| Traefik | Ja |
| Konten | lokal (Robin); SSO folgt in E8 |
## Stack
| Container | Image | Zweck |
|---|---|---|
| `paperless_ngx` | `paperless-ngx:3.1.3` | App, Consumer, KI-Client |
| `paperless_db` | `postgres:18.6` | Datenbank (SSD) |
| `paperless_redis` | `redis:8.10.1` | Broker |
| `gotenberg` | `gotenberg:8.37.0` | Mail/Office → PDF |
| `tika` | `tika:3.3.1.0` | Textextraktion |
| `protonmail-bridge` | `enucatl/protonmail-bridge:3.26.0` | IMAP-Brücke zu Proton, nur intern (Port 1243) |
## Konfigurationsbesonderheiten
- **Secrets über den Infisical-Agent:** `PAPERLESS_SECRET_KEY`, `POSTGRES_PASSWORD`, `PAPERLESS_DBPASS` liegen unter
`/paperless-ngx` und werden nach `/run/infisical/paperless-ngx.env` gerendert (`env_file`). Keine Secrets im Repo.
- **v3-Pflichten:** `PAPERLESS_DBENGINE=postgresql`, `OCR_MODE=auto`, `ARCHIVE_FILE_GENERATION=always` (jedes Dokument
bekommt ein Archiv-PDF, sonst wäre `documents/archive/` unvollständig), `TRUSTED_PROXIES=10.100.0.0/16` gegen
Login-403 hinter Traefik.
- **Zulauf Papier:** HP 4302 → Samba-Freigabe `[paperless]` → `consume/robin/`. Unterordner = Eigentümer (kein Tag).
ASN-Barcodes (`ASN00042`) setzen die Archivnummer **und** trennen den Stapel: an jeder Seite mit Etikett beginnt ein
neues Dokument, die Seite bleibt erhalten. Doppelte ASN → Dokument wird abgewiesen (Aufgaben-Seite).
- **Zulauf Mail:** Proton-Label „Paperless" (von Hand oder per Proton-Filter) → Mail-Regel liest `Labels/Paperless`
über die Bridge, PDF-Anhänge werden Dokumente (Eigentümer Robin, Tags `Inbox` + `E-Mail`). Verarbeitete Mails merkt
sich Paperless per UID (*Mail → Verarbeitete E-Mails*).
- **Ordnerlogik:** Speicherpfade je Lebensbereich, erste Ebene ist der Eigentümer, `_Unsortiert` als Auffang
(`PAPERLESS_FILENAME_FORMAT`). Dateien werden nur über Metadaten verschoben, nie von Hand.
- **KI:** `openai-like` gegen llama-swap `10.10.10.7:9292` — `brain-llamacpp` für Vorschläge, `embed-llamacpp` für
den RAG-Index (täglich 03:30). Workflow „Dokument hinzugefügt → KI-Vorschläge anwenden" setzt Titel, Datum,
Korrespondent (darf anlegen), Typ, Speicherpfad und Tags (nur bestehende). **Die KI-Felder in Einstellungen →
Anwendungskonfiguration bleiben leer**, sonst schlägt die Datenbank die Compose-Datei.
- **Postgres-Tuning:** `shared_buffers=256MB`, `effective_cache_size=512MB`, `work_mem=16MB`.
- **NFS:** Media/Export/Trash liegen auf `/mnt/paperless` (UNAS). UID/GID 977/988 = Systembenutzer `paperless` auf dem
NUC = Container-Identität = UNAS-Export.
## Host-Bausteine (`host-config/`, `scripts/`)
| Datei | Zweck |
|---|---|
| `host-config/smb-paperless.conf` | Freigabe `[paperless]`, per `include` in `/etc/samba/smb.conf` |
| `host-config/paperless-export.{service,timer}` | `document_exporter` täglich 01:15 nach `/mnt/paperless/export` |
| `host-config/paperless-consume-collector.{service,timer}` | alle 15 min `paperless.prom` für den Textfile-Collector |
| `scripts/consume-stale-collector.sh` | zählt Dateien > 30 min im Consume-Ordner |
| `seed/schema.yaml`, `seed/seed.py` | Startschema, idempotent über die REST-API |
Dazu setzt das Runbook (Plan Teil C) zwei globale Samba-Werte von Hand: `server min protocol = SMB2` und
`map to guest = Never`; die `include`-Zeile für die Freigabe muss die letzte Zeile von `[global]` sein.
Installation der Units (NUC, root):
```bash
cp /opt/rpi-docker-compose/paperless-ngx/host-config/paperless-*.service \
/opt/rpi-docker-compose/paperless-ngx/host-config/paperless-*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now paperless-export.timer paperless-consume-collector.timer
```
## Ersteinrichtung
```bash
# Superuser nach dem ersten Start (Passwort in den Passwortmanager)
docker exec -it paperless_ngx python3 manage.py createsuperuser
# Proton Bridge einmalig anmelden (2FA). Vorher den Run-Container stoppen, er hält den
# Single-Instance-Lock unter /data. Danach: login → info → exit
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml stop protonmail-bridge
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml --profile init run --rm protonmail-bridge-init
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml up -d protonmail-bridge
# Startschema (vom Laptop; Token aus Mein Profil → API-Auth-Token)
PAPERLESS_URL=https://paperless.home.robinwerner.net PAPERLESS_TOKEN=… uv run paperless-ngx/seed/seed.py --dry-run
```
Nach dem ersten Login die UI-Sprache auf **Deutsch** stellen (KI-Vorschläge folgen ihr).
## Backup / Restore
- `/mnt/ssd/container-data` (DB, Index, Consume, Bridge) und `/mnt/paperless` (Media, Export, Trash) sind in beiden
borgmatic-Ketten; der DB-Dump greift automatisch (`predeploy-backup.sh`).
- Der nächtliche Export ist Paperless' eigenes Sicherungsformat (Originale, Archive, Manifest mit allen Metadaten) und
lässt sich mit `document_importer` in eine leere Instanz zurückspielen. Nachweis:
`borgmatic list --archive latest --path mnt/paperless/export` (nicht `--find`).
- Restore-Probe: E7b.
## Wichtige Pfade
| Pfad | Zweck |
|---|---|
| `/mnt/ssd/container-data/paperless/data` | Index, Klassifikator, LLM-Index |
| `/mnt/ssd/container-data/paperless/db` | Postgres |
| `/mnt/ssd/container-data/paperless/redis` | Redis |
| `/mnt/ssd/container-data/paperless/consume/robin` | Zulauf (Samba `[paperless]`) |
| `/mnt/ssd/container-data/paperless/protonmail-bridge` | Bridge-Vault und Mail-Cache (UID 1000) |
| `/mnt/paperless/media` | Dokumente (UNAS) |
| `/mnt/paperless/export` | nächtlicher Export (UNAS) |
| `/mnt/paperless/trash` | Papierkorb (UNAS) |
| `/mnt/ssd/container-data/monitoring-stack/textfile/paperless.prom` | Consume-Metriken |
## Offen (E7b)
Dropbox-Spiegel · Restore-Probe · Lars' Konto und Eigentümertrennung · SSO (E8) · Vision-OCR über paperless-gpt,
falls die OCR-Schwelle reißt · „Mail als PDF" in der Mail-Regel · Custom Fields
- Step 2: Commit
git add paperless-ngx/docs.md
git commit -m "docs(paperless): describe the E7 stack, feeds, AI and host units"
Task A6: PR-A öffnen¶
Files: keine
- Step 1: Push + PR (Body als Datei im Scratchpad, nur beschreiben, was im Diff ist)
git push -u origin feat/paperless-v3-e7
gh pr create --base main --title "feat(paperless): E7 rebuild on paperless-ngx 3.1 with native AI and Proton Bridge" --body-file <scratchpad>/pr-a.md
Inhalt von pr-a.md: Stack auf 3.1.3 neu, v3-Migration der Einstellungen, native KI gegen llama-swap, Bridge-Container
mit Init-Profil, Secrets über Infisical-Agent, getrackte paperless.env entfernt, docs.md neu. Deploy-Hinweis:
Teil C dieses Plans (NUC-Schritte durch Robin). Abschluss mit der Attributionszeile aus der Session.
- Step 2: Prüfen
Run: gh pr view --json url,state -q '.url + " " + .state'
Expected: URL + OPEN
Teil B — Repo: Host-Bausteine, Seed, Monitoring (Branch feat/paperless-v3-host)¶
Task B1: Arbeits-Branch¶
- Step 1
cd /home/robin/repos/homeserver
git switch -c feat/paperless-v3-host origin/main
mkdir -p paperless-ngx/host-config paperless-ngx/scripts paperless-ngx/seed/tests
- Step 2: Ignore-Regeln prüfen
Run: git check-ignore -v paperless-ngx/host-config/x paperless-ngx/scripts/x paperless-ngx/seed/x; echo "exit=$?"
Expected: keine Ausgabe, exit=1 (nichts ist ignoriert)
Task B2: Samba-Freigabe als Include-Datei¶
Files:
- Create: paperless-ngx/host-config/smb-paperless.conf
Interfaces:
- Consumes: Systembenutzer/-gruppe paperless (977/988) und Verzeichnis consume/robin (Task C2)
- Produces: Freigabe \\10.10.10.3\paperless für Quick Set „Paperless" (Task D4)
- Step 1: Datei anlegen
; Paperless consume share for the HP 4302 (E7, homeserver#190).
; Included from /etc/samba/smb.conf via
; include = /opt/rpi-docker-compose/paperless-ngx/host-config/smb-paperless.conf
; Put that include as the LAST line of [global]: the section header below
; switches the current section for everything that follows.
; Files are forced to the paperless identity (UID 977 / GID 988) so the
; consumer inside the container can read and delete them without any
; permission juggling. The scanner authenticates as the existing user
; "scanner" — the device stores exactly one credential per Quick Set.
[paperless]
comment = Paperless consume (Robin)
path = /mnt/ssd/container-data/paperless/consume/robin
valid users = scanner
force user = paperless
force group = paperless
create mask = 0660
directory mask = 0770
browseable = no
guest ok = no
writable = yes
- Step 2: Commit
git add paperless-ngx/host-config/smb-paperless.conf
git commit -m "feat(paperless): samba share definition for the scanner consume folder"
Task B3: Exporter-Timer¶
Files:
- Create: paperless-ngx/host-config/paperless-export.service
- Create: paperless-ngx/host-config/paperless-export.timer
Interfaces:
- Consumes: Container paperless_ngx (Task A3), Mount /mnt/paperless/export
- Produces: Exportbaum /mnt/paperless/export/{originals,archive,thumbnails,json} + manifest.json (Task F4 prüft)
- Step 1: Service
[Unit]
Description=Export Paperless documents and metadata to /mnt/paperless/export
Documentation=https://github.com/SWATPeaceKeeper/homeserver/issues/190
# Runs inside the paperless_ngx container, so Docker has to be up.
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
# document_exporter is Paperless' own backup format: originals, archive PDFs
# and a manifest with the complete database content (tags, correspondents,
# ASN, workflows, mail rules, permissions). It is restorable into an empty
# instance regardless of Postgres version or paths — the pg_dump is not.
# --delete mirror the current state, drop files of deleted docs
# --use-folder-prefix originals/ archive/ thumbnails/ json/
# --split-manifest one json per document, so borg deduplicates changes
ExecStart=/usr/bin/docker exec paperless_ngx document_exporter ../export --delete --use-folder-prefix --split-manifest --no-progress-bar
# First run exports everything; later runs only write what changed.
TimeoutStartSec=3h
- Step 2: Timer
[Unit]
Description=Nightly Paperless export, 49 minutes before the borgmatic run
Documentation=https://github.com/SWATPeaceKeeper/homeserver/issues/190
[Timer]
# borgmatic starts at 02:04; the export has to be finished by then so the
# night's archive carries the current manifest.
OnCalendar=*-*-* 01:15:00
AccuracySec=1min
# No Persistent=true on purpose: a catch-up run right after a late boot
# could overlap the borg run. Missing one night's export costs nothing,
# the previous export is still in the archive.
[Install]
WantedBy=timers.target
- Step 3: Syntax prüfen (lokal;
systemd-analyzeprüft nur die Unit-Grammatik)
Run: systemd-analyze verify paperless-ngx/host-config/paperless-export.service paperless-ngx/host-config/paperless-export.timer
Expected: keine Ausgabe (Warnung „Unit is not loaded" darf erscheinen)
- Step 4: Commit
git add paperless-ngx/host-config/paperless-export.service paperless-ngx/host-config/paperless-export.timer
git commit -m "feat(paperless): nightly document_exporter timer ahead of the borg run"
Task B4: Consume-Stau-Collector¶
Files:
- Create: paperless-ngx/scripts/consume-stale-collector.sh
- Create: paperless-ngx/host-config/paperless-consume-collector.service
- Create: paperless-ngx/host-config/paperless-consume-collector.timer
Interfaces:
- Produces: Metriken paperless_consume_stale_files, paperless_consume_files,
paperless_consume_collector_timestamp_seconds in
/mnt/ssd/container-data/monitoring-stack/textfile/paperless.prom (Task B5 alarmiert darauf)
- Step 1: Skript
#!/usr/bin/env bash
# Files waiting in the Paperless consume directory as Prometheus gauges.
#
# The consume directory is the one place where a document can get lost
# silently: a file the consumer never picks up just sits there, Paperless
# reports nothing, and the scan looks "done" at the device. With the old
# Syncthing feed that went unnoticed for nine months (E7, homeserver#190).
#
# Called every 15 minutes by paperless-consume-collector.timer. Dotfiles
# are ignored: macOS clients drop ._* and .DS_Store, and Paperless skips
# them too. The timestamp gauge lets Prometheus notice a dead collector.
set -euo pipefail
CONSUME_DIR=${CONSUME_DIR:-/mnt/ssd/container-data/paperless/consume}
TEXTFILE_DIR=${TEXTFILE_DIR:-/mnt/ssd/container-data/monitoring-stack/textfile}
MAX_AGE_MIN=${MAX_AGE_MIN:-30}
OUT="$TEXTFILE_DIR/paperless.prom"
TMP="$TEXTFILE_DIR/.paperless.prom.$$"
trap 'rm -f "$TMP"' EXIT
stale=$(find "$CONSUME_DIR" -type f ! -name '.*' -mmin +"$MAX_AGE_MIN" | wc -l)
total=$(find "$CONSUME_DIR" -type f ! -name '.*' | wc -l)
{
echo "# HELP paperless_consume_stale_files Files older than ${MAX_AGE_MIN} minutes still waiting in the consume directory."
echo "# TYPE paperless_consume_stale_files gauge"
echo "paperless_consume_stale_files $stale"
echo "# HELP paperless_consume_files Files currently in the consume directory."
echo "# TYPE paperless_consume_files gauge"
echo "paperless_consume_files $total"
echo "# HELP paperless_consume_collector_timestamp_seconds Unix time of the last collector run."
echo "# TYPE paperless_consume_collector_timestamp_seconds gauge"
echo "paperless_consume_collector_timestamp_seconds $(date +%s)"
} >"$TMP"
# Atomic replace so node-exporter never reads a half-written file.
mv "$TMP" "$OUT"
- Step 2: Lint und Format
chmod +x paperless-ngx/scripts/consume-stale-collector.sh
shellcheck paperless-ngx/scripts/consume-stale-collector.sh
shfmt -i 2 -d paperless-ngx/scripts/consume-stale-collector.sh
Expected: beide ohne Ausgabe
- Step 3: Funktionstest mit Wegwerf-Verzeichnissen (eine alte, eine frische, eine Dotfile)
T=$(mktemp -d)
mkdir -p "$T/consume/robin" "$T/textfile"
touch -d '-2 hours' "$T/consume/robin/alt.pdf"
touch "$T/consume/robin/neu.pdf" "$T/consume/robin/._neu.pdf"
CONSUME_DIR="$T/consume" TEXTFILE_DIR="$T/textfile" paperless-ngx/scripts/consume-stale-collector.sh
grep -E '^paperless_consume_(stale_files|files) ' "$T/textfile/paperless.prom"
ls -a "$T/textfile"
rm -rf "$T"
Expected: paperless_consume_stale_files 1, paperless_consume_files 2, im Textfile-Verzeichnis nur paperless.prom
(keine .paperless.prom.*-Reste)
-
Step 4: Gegenprobe, dass der Test scheitern kann —
MAX_AGE_MIN=300setzen und erneut ausführen: erwartetpaperless_consume_stale_files 0. Danach Wegwerf-Verzeichnis löschen. -
Step 5: Service (Härtung wie
nut/host-config/ups-textfile-collector.service)
[Unit]
Description=Write Paperless consume-directory gauges to the node-exporter textfile directory
Documentation=https://github.com/SWATPeaceKeeper/homeserver/issues/190
[Service]
Type=oneshot
ExecStart=/opt/rpi-docker-compose/paperless-ngx/scripts/consume-stale-collector.sh
# The script reads one directory and writes one file. Nothing here needs
# privileges — same reasoning as the UPS collector (nut/host-config).
NoNewPrivileges=true
CapabilityBoundingSet=
# The consume tree is paperless:paperless 0770 (Samba directory mask); group
# membership is enough to traverse it, no capability needed.
SupplementaryGroups=paperless
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateNetwork=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_UNIX
ReadWritePaths=/mnt/ssd/container-data/monitoring-stack/textfile
# Four journal lines per run every 15 minutes is still noise; keep warnings.
LogLevelMax=warning
- Step 6: Timer
[Unit]
Description=Collect Paperless consume-directory gauges every 15 minutes
Documentation=https://github.com/SWATPeaceKeeper/homeserver/issues/190
[Timer]
# The alert fires after 30 minutes of a stale file; 15 minutes gives two
# samples inside that window. Faster brings nothing — the consumer itself
# needs a few minutes for OCR on a long scan.
OnBootSec=5min
OnUnitActiveSec=15min
AccuracySec=1min
[Install]
WantedBy=timers.target
- Step 7: Unit-Syntax
Run: systemd-analyze verify paperless-ngx/host-config/paperless-consume-collector.service paperless-ngx/host-config/paperless-consume-collector.timer
Expected: keine Fehler („Unit is not loaded" und „ExecStart path does not exist" sind lokal erwartbar)
- Step 8: Commit
git add paperless-ngx/scripts/consume-stale-collector.sh
git add paperless-ngx/host-config/paperless-consume-collector.service paperless-ngx/host-config/paperless-consume-collector.timer
git commit -m "feat(paperless): consume-directory gauges via node-exporter textfile collector"
Task B5: Alarmregeln¶
Files:
- Create: monitoring-stack/configs/prometheus/rules/paperless-alerts.yml
- Modify: monitoring-stack/configs/prometheus/rules/container-alerts.yml (Ausdruck von ContainerDown)
Interfaces:
- Consumes: Metriken aus Task B4, container_last_seen von cAdvisor
- Step 1: Neue Regeldatei
# Alert rules for the Paperless stack (E7, homeserver#190).
#
# Paperless itself never reports a file the consumer did not pick up. The
# gauges come from paperless-ngx/scripts/consume-stale-collector.sh, written
# every 15 minutes through the node-exporter textfile collector.
#
# Check both metrics against /api/v1/query after the first collector run
# before trusting these rules (silent-failure-alerts.yml explains why).
groups:
- name: paperless-alerts
rules:
# WARNING: a document is waiting and nobody will notice otherwise.
- alert: PaperlessConsumeStuck
expr: paperless_consume_stale_files > 0
for: 30m
labels:
severity: warning
annotations:
summary: "Paperless: {{ $value }} Datei(en) hängen im Consume-Ordner"
description: >
Seit über 30 Minuten liegen Dateien in
/mnt/ssd/container-data/paperless/consume, die der Consumer nicht
abgeholt hat. Prüfen: docker logs paperless_ngx, Aufgaben-Seite in
Paperless, Eigentümer der Datei (muss paperless/977 sein).
# WARNING: the collector itself died. Without this rule a broken timer
# would silence PaperlessConsumeStuck for good (same pattern as
# AiserverBackupMetricMissing).
- alert: PaperlessConsumeCollectorStale
expr: >
absent(paperless_consume_collector_timestamp_seconds) or
(time() - paperless_consume_collector_timestamp_seconds > 3600)
for: 15m
labels:
severity: warning
annotations:
summary: "Paperless-Consume-Collector liefert seit über einer Stunde nichts"
description: >
paperless-consume-collector.timer auf dem NUC schreibt nicht mehr.
Prüfen: systemctl status paperless-consume-collector.timer,
journalctl -u paperless-consume-collector.
- Step 2:
paperless_ngxinContainerDownaufnehmen — incontainer-alerts.ymlnach der Zeileabsent(container_last_seen{name="pihole"}) oreinfügen:
- Step 3: Regeln prüfen (promtool aus dem gepinnten Prometheus-Image)
docker run --rm --entrypoint promtool \
-v "$PWD/monitoring-stack/configs/prometheus/rules:/rules:ro" \
prom/prometheus:v3.13.1 check rules /rules/paperless-alerts.yml /rules/container-alerts.yml
Expected: SUCCESS: 2 rules found und SUCCESS: 1 rules found
- Step 4: Commit
git add monitoring-stack/configs/prometheus/rules/paperless-alerts.yml monitoring-stack/configs/prometheus/rules/container-alerts.yml
git commit -m "feat(monitoring): alert on stuck Paperless consume files and a dead collector"
Task B6: Seed-Skript mit Startschema (TDD)¶
Files:
- Create: paperless-ngx/seed/schema.yaml
- Create: paperless-ngx/seed/seed.py
- Create: paperless-ngx/seed/tests/test_seed.py
Interfaces:
- Consumes: REST-API GET/POST /api/{tags,document_types,storage_paths,correspondents}/, Header
Authorization: Token <PAPERLESS_TOKEN>
- Produces: Funktionen load_schema(path) -> dict[str, list[dict]], plan_missing(wanted, existing) -> list[dict],
fetch_existing_names(session, base_url, endpoint) -> set[str], build_payload(section, item) -> dict,
main(argv) -> int; CLI uv run paperless-ngx/seed/seed.py [--dry-run] [--schema PATH]
- Step 1: Failing Tests schreiben (
paperless-ngx/seed/tests/test_seed.py)
"""Tests for the Paperless taxonomy seed (pure logic, no network)."""
import pytest
import seed
def test_plan_missing_ignores_case():
wanted = [{"name": "Inbox"}, {"name": "Steuer"}]
assert seed.plan_missing(wanted, {"inbox"}) == [{"name": "Steuer"}]
def test_plan_missing_keeps_payload_fields():
wanted = [{"name": "Arbeit", "path": "x/{{ title }}"}]
assert seed.plan_missing(wanted, set()) == wanted
def test_load_schema_rejects_entry_without_name(tmp_path):
schema = tmp_path / "schema.yaml"
schema.write_text("tags:\n - color: '#ff0000'\n", encoding="utf-8")
with pytest.raises(ValueError, match="needs a 'name'"):
seed.load_schema(schema)
def test_load_schema_fills_missing_sections(tmp_path):
schema = tmp_path / "schema.yaml"
schema.write_text("tags:\n - name: Inbox\n", encoding="utf-8")
loaded = seed.load_schema(schema)
assert loaded["tags"] == [{"name": "Inbox"}]
assert loaded["correspondents"] == []
def test_build_payload_defaults_matching_but_lets_item_override():
assert seed.build_payload("correspondents", {"name": "HUK"}) == {
"name": "HUK",
"matching_algorithm": 6,
}
inbox = seed.build_payload("tags", {"name": "Inbox", "is_inbox_tag": True})
assert inbox["matching_algorithm"] == 0
assert inbox["is_inbox_tag"] is True
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class FakeSession:
def __init__(self, pages):
self.pages = pages
self.calls = []
def get(self, url, timeout):
self.calls.append(url)
return FakeResponse(self.pages[url])
def test_fetch_existing_names_follows_pagination():
base = "https://paperless.example"
pages = {
f"{base}/api/tags/?page_size=1000": {
"results": [{"name": "Inbox"}],
"next": f"{base}/api/tags/?page=2",
},
f"{base}/api/tags/?page=2": {"results": [{"name": "Steuer"}], "next": None},
}
session = FakeSession(pages)
assert seed.fetch_existing_names(session, base, "/api/tags/") == {"Inbox", "Steuer"}
assert len(session.calls) == 2
class FakePostResponse:
def __init__(self, status_code, text):
self.status_code = status_code
self.text = text
class FakePostSession:
"""Fake session whose post() always answers with a fixed error response."""
def __init__(self, status_code, text):
self.status_code = status_code
self.text = text
def post(self, url, json, timeout):
return FakePostResponse(self.status_code, self.text)
def test_create_items_raises_systemexit_with_server_body():
session = FakePostSession(400, "Invalid variable detected.")
with pytest.raises(SystemExit) as excinfo:
seed.create_items(
session, "https://paperless.example", "tags", [{"name": "Inbox"}]
)
message = str(excinfo.value)
assert "/api/tags/" in message
assert "Invalid variable detected." in message
- Step 2: Tests laufen lassen, Scheitern bestätigen
Run: PYTHONPATH=paperless-ngx/seed uv run --with pytest==9.1.1 --with requests==2.34.2 --with pyyaml==6.0.3 pytest paperless-ngx/seed/tests -q
Expected: ModuleNotFoundError: No module named 'seed'
- Step 3:
seed.pyschreiben
# /// script
# requires-python = ">=3.11"
# dependencies = ["requests==2.34.2", "pyyaml==6.0.3"]
# ///
"""Seed the Paperless-ngx taxonomy: tags, document types, storage paths, correspondents.
Reads schema.yaml next to this file and creates every object that does not exist
yet (matched by name, case-insensitive). Existing objects are never changed, so
the script can run any number of times.
Usage:
PAPERLESS_URL=https://paperless.home.robinwerner.net \\
PAPERLESS_TOKEN=<api token> uv run paperless-ngx/seed/seed.py [--dry-run]
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import Any
import requests
import yaml
SECTIONS: dict[str, str] = {
"tags": "/api/tags/",
"document_types": "/api/document_types/",
"storage_paths": "/api/storage_paths/",
"correspondents": "/api/correspondents/",
}
# Paperless matching algorithms: 0 = none, 6 = auto (learned classifier).
# Tags and storage paths are assigned by workflows and AI, not by matching.
DEFAULT_MATCHING: dict[str, int] = {
"tags": 0,
"document_types": 6,
"storage_paths": 0,
"correspondents": 6,
}
def load_schema(path: Path) -> dict[str, list[dict[str, Any]]]:
"""Load schema.yaml; every section becomes a list of dicts that carry a name."""
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
schema: dict[str, list[dict[str, Any]]] = {}
for section in SECTIONS:
items = data.get(section) or []
if not isinstance(items, list):
raise ValueError(f"{path}: section '{section}' must be a list")
for item in items:
if not isinstance(item, dict) or not item.get("name"):
raise ValueError(f"{path}: every entry in '{section}' needs a 'name'")
schema[section] = items
return schema
def plan_missing(wanted: list[dict[str, Any]], existing: set[str]) -> list[dict[str, Any]]:
"""Return the wanted items whose name is not in existing (case-insensitive)."""
known = {name.casefold() for name in existing}
return [item for item in wanted if item["name"].casefold() not in known]
def fetch_existing_names(session: Any, base_url: str, endpoint: str) -> set[str]:
"""Collect the names behind a paginated Paperless list endpoint."""
names: set[str] = set()
url: str | None = f"{base_url}{endpoint}?page_size=1000"
while url:
response = session.get(url, timeout=30)
response.raise_for_status()
payload = response.json()
names.update(result["name"] for result in payload["results"])
url = payload.get("next")
return names
def build_payload(section: str, item: dict[str, Any]) -> dict[str, Any]:
"""Turn a schema entry into the JSON body Paperless expects."""
payload: dict[str, Any] = {"matching_algorithm": DEFAULT_MATCHING[section]}
payload.update(item)
return payload
def create_items(
session: requests.Session, base_url: str, section: str, items: list[dict[str, Any]]
) -> int:
"""POST every item; stop at the first error with the server's answer."""
endpoint = SECTIONS[section]
for item in items:
response = session.post(
f"{base_url}{endpoint}", json=build_payload(section, item), timeout=30
)
if response.status_code >= 400:
raise SystemExit(
f"POST {endpoint} '{item['name']}' failed: "
f"{response.status_code} {response.text}"
)
print(f" + {section}: {item['name']}")
return len(items)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="only list what is missing")
parser.add_argument(
"--schema", type=Path, default=Path(__file__).with_name("schema.yaml")
)
args = parser.parse_args(argv)
base_url = os.environ.get("PAPERLESS_URL", "").rstrip("/")
token = os.environ.get("PAPERLESS_TOKEN", "")
if not base_url or not token:
print("PAPERLESS_URL and PAPERLESS_TOKEN must be set", file=sys.stderr)
return 2
schema = load_schema(args.schema)
session = requests.Session()
session.headers.update({"Authorization": f"Token {token}", "Accept": "application/json"})
created = 0
for section, endpoint in SECTIONS.items():
existing = fetch_existing_names(session, base_url, endpoint)
missing = plan_missing(schema[section], existing)
print(f"{section}: {len(existing)} vorhanden, {len(missing)} fehlen")
if args.dry_run:
for item in missing:
print(f" ~ {section}: {item['name']}")
continue
created += create_items(session, base_url, section, missing)
print(f"{created} Objekte angelegt")
return 0
if __name__ == "__main__":
raise SystemExit(main())
- Step 4: Tests laufen lassen
Run: PYTHONPATH=paperless-ngx/seed uv run --with pytest==9.1.1 --with requests==2.34.2 --with pyyaml==6.0.3 pytest paperless-ngx/seed/tests -q
Expected: 7 passed
- Step 5: Lint
Run: uvx ruff check paperless-ngx/seed && uvx ruff format --check paperless-ngx/seed
Expected: All checks passed! und „already formatted"
- Step 6:
schema.yaml(Korrespondenten trägt Robin ein, siehe Task D1)
# Startschema für Paperless (E7). seed.py legt fehlende Objekte an und lässt
# vorhandene unangetastet — der Name ist der Schlüssel.
#
# Speicherpfad-Vorlagen (Jinja): Eigentümer / Lebensbereich / Korrespondent /
# Jahr / "Datum Titel ASN00042". Die ASN im Dateinamen ist der Rückweg zum
# Papier. Fehlt eine Angabe, fällt ihr Segment weg statt "-none-" zu erzeugen.
tags:
- name: Inbox
color: "#6c757d"
is_inbox_tag: true
- name: Handeln
color: "#dc3545"
- name: Steuer
color: "#ffc107"
- name: Dauerhaft
color: "#0d6efd"
- name: E-Mail
color: "#20c997"
- name: Familie
color: "#d63384"
- name: Garantie
color: "#fd7e14"
document_types:
- name: Rechnung
- name: Vertrag
- name: Bescheid
- name: Schreiben
- name: Zeugnis/Urkunde
- name: Gehaltsabrechnung
- name: Kontoauszug
- name: Versicherungspolice
- name: Kündigung
- name: Beleg/Quittung
- name: Antrag/Formular
- name: Steuerbescheid
- name: Bestellung/Auftragsbestätigung
- name: Sonstiges
storage_paths:
- name: Arbeit
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Arbeit/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
- name: Finanzen
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Finanzen/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
- name: Versicherungen
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Versicherungen/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
- name: Wohnung
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Wohnung/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
- name: Gesundheit
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Gesundheit/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
- name: Behörden
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Behörden/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
- name: Studium & Zeugnisse
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Studium & Zeugnisse/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
- name: Fahrzeug
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Fahrzeug/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
- name: Verträge & Abos
path: "{% if owner_username %}{{ owner_username }}/{% endif %}Verträge & Abos/{% if correspondent %}{{ correspondent }}/{% endif %}{{ created_year }}/{{ created }} {{ title }}{% if asn %} ASN{{ '%05d' | format(asn | int) }}{% endif %}"
# Regelmäßige Absender, Name wie im Briefkopf. Die KI legt weitere im Betrieb an.
correspondents: []
- Step 7: Schema lädt
Run: PYTHONPATH=paperless-ngx/seed uv run --with pyyaml==6.0.3 --with requests==2.34.2 python3 -c "import seed, pathlib; s = seed.load_schema(pathlib.Path('paperless-ngx/seed/schema.yaml')); print({k: len(v) for k, v in s.items()})"
Expected: {'tags': 7, 'document_types': 14, 'storage_paths': 9, 'correspondents': 0}
- Step 8: Commit
git add paperless-ngx/seed/schema.yaml paperless-ngx/seed/seed.py paperless-ngx/seed/tests/test_seed.py
git commit -m "feat(paperless): idempotent taxonomy seed with the E7 start schema"
Task B7: PR-B öffnen¶
- Step 1
git push -u origin feat/paperless-v3-host
gh pr create --base main --title "feat(paperless): host units, consume gauges, alert rules and taxonomy seed" --body-file <scratchpad>/pr-b.md
Body: die fünf Bausteine aus Teil B je ein Satz, Hinweis „Deploy: Teil C/F des Plans, Robins Hand". Attributionszeile.
- Step 2: Prüfen
Run: gh pr view --json url,state -q '.url + " " + .state'
Expected: URL + OPEN
Teil C — NUC-Cutover [ROBIN/NUC] (nach Merge von PR-A und PR-B)¶
Task C1: Infisical-Secrets und Agent-Template¶
- Step 1: Werte lokal erzeugen (Laptop), nicht in den Chat kopieren
uv run python3 -c "import secrets; print('SECRET_KEY', secrets.token_urlsafe(64))"
openssl rand -hex 32 # DB-Passwort
-
Step 2: In der Infisical-UI (EU) unter Pfad
/paperless-ngx, Envdevanlegen:PAPERLESS_SECRET_KEY,POSTGRES_PASSWORD,PAPERLESS_DBPASS(derselbe Wert wiePOSTGRES_PASSWORD). Machine Identitynuc-agentLese-Zugriff auf/paperless-ngxgeben. -
Step 3:
[NUC root]Template-Block ausagent.yaml.examplein/etc/infisical/agent.yamlübernehmen
ssh nuc-root
grep -n 'paperless-ngx' /etc/infisical/agent.yaml || echo "Block fehlt -> aus /opt/rpi-docker-compose/infrastructure/infisical-agent/agent.yaml.example übernehmen"
systemctl reload infisical-agent.service
sleep 5
cut -d= -f1 /run/infisical/paperless-ngx.env
Expected: drei Zeilen PAPERLESS_SECRET_KEY, POSTGRES_PASSWORD, PAPERLESS_DBPASS
Task C2: Systembenutzer und Verzeichnisse¶
- Step 1:
[NUC root]IDs frei?
- Step 2:
[NUC root]Benutzer anlegen
groupadd --gid 988 paperless
useradd --system --uid 977 --gid 988 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin paperless
id paperless
Expected: uid=977(paperless) gid=988(paperless) groups=988(paperless)
- Step 3:
[NUC root]Consume-Zweig und Bridge-Verzeichnis
install -d -o paperless -g paperless -m 0770 /mnt/ssd/container-data/paperless/consume/robin
install -d -o 1000 -g 1000 -m 0700 /mnt/ssd/container-data/paperless/protonmail-bridge
ls -ln /mnt/ssd/container-data/paperless/
Expected: consume (977/988), protonmail-bridge (1000/1000), daneben data, db, redis
Task C3: Samba¶
- Step 1:
[NUC root]SMB1-Clients prüfen (vor dem Anheben)
Expected: keine aktiven NT1-Sitzungen. Der 4302 spricht SMB2/3, macOS ebenso.
- Step 2:
[NUC root]smb.confanpassen — in[global]:
server min protocol = SMB2
map to guest = Never
include = /opt/rpi-docker-compose/paperless-ngx/host-config/smb-paperless.conf
(server min protocol = NT1 und map to guest = Bad Password ersetzen.) Die include-Zeile muss die letzte
Zeile von [global] sein: Samba fügt die Datei wörtlich ein, und ihr [paperless]-Header wechselt den Abschnitt
für alles, was danach kommt. Globale Werte hinter dem Include landen sonst still in der Freigabe.
- Step 3:
[NUC root]Prüfen und laden
testparm -s 2>&1 | grep -A12 '^\[paperless\]'
testparm -s 2>&1 | grep -E 'server min protocol|map to guest'
testparm -s 2>&1 | grep -i 'global parameter'
systemctl reload smbd
smbclient -L //127.0.0.1 -U scanner
Expected: Freigabe paperless erscheint mit force user = paperless; server min protocol = SMB2;
map to guest = Never; die Suche nach global parameter bleibt leer (sonst steht das Include zu früh);
smbclient listet paperless nicht (browseable = no) aber scanner. Das Passwort des
Benutzers scanner ist dasselbe wie im bestehenden Quick Set „Ablage".
- Step 4: Schreibtest vom Laptop
echo test > /tmp/smbtest.txt
smbclient //10.10.10.3/paperless -U scanner -c 'put /tmp/smbtest.txt smbtest.txt; ls'
Dann [NUC]: ls -ln /mnt/ssd/container-data/paperless/consume/robin/ → smbtest.txt gehört 977/988,
Rechte -rw-rw----. Datei danach löschen (rm), sonst konsumiert Paperless sie später als Dokument.
Task C4: Alten Stack ersetzen¶
- Step 1:
[NUC root]Repo aktualisieren, Rechte prüfen
Expected: nur traefik/.env und ts3/.env
- Step 2:
[NUC]Alten Stack stoppen
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml down
docker ps -a --filter name=paperless --filter name=gotenberg --filter name=tika
Expected: keine Container mehr
- Step 3:
[NUC root]Sicherheitsblick, dann leeren (DESTRUKTIV)
find /mnt/paperless/media -type f | wc -l # erwartet: Dateien genau eines Testdokuments
ls /mnt/ssd/container-data/paperless/db
for d in /mnt/ssd/container-data/paperless/db /mnt/ssd/container-data/paperless/redis \
/mnt/ssd/container-data/paperless/data /mnt/paperless/media /mnt/paperless/export /mnt/paperless/trash; do
find "$d" -mindepth 1 -delete
done
find /mnt/paperless /mnt/ssd/container-data/paperless -mindepth 2 | wc -l
Expected: letzte Zeile 1 (nur consume/robin); consume/, protonmail-bridge/ und die Mount-Verzeichnisse bleiben
stehen. /mnt/scanner wird nicht angefasst.
- Step 4:
[NUC]Konfiguration autoritativ prüfen und starten
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml config --quiet && echo OK
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml up -d
sleep 90
docker ps --filter name=paperless --filter name=gotenberg --filter name=tika --filter name=protonmail --format '{{.Names}}\t{{.Status}}'
Expected: paperless_ngx, paperless_db, paperless_redis mit (healthy); gotenberg, tika Up;
protonmail-bridge Up (bis zur Anmeldung in Task E2 ohne Konto, aber gesund). protonmail-bridge-init läuft
nicht (Profil).
- Step 5:
[NUC]Startlog auf v3-Warnungen prüfen
Run: docker logs paperless_ngx 2>&1 | grep -iE 'deprecat|removed|warning|error' | head -20
Expected: keine Zeile zu entfernten Einstellungen; KI-Verbindungsfehler dürfen hier noch nicht auftreten (erst bei
der ersten Anfrage)
Task C5: Superuser, Sprache, Token¶
-
Step 1:
[NUC]docker exec -it paperless_ngx python3 manage.py createsuperuser— Benutzernamerobin, Passwort in den Passwortmanager. -
Step 2: Login unter
https://paperless.home.robinwerner.netohne 403 → Einstellungen → Sprache Deutsch. -
Step 3: API-Token unter Mein Profil → API-Auth-Token erzeugen. Robin gibt ihn dem Seed nur als Umgebungsvariable (Task D1), nicht in den Chat.
-
Step 4: Anwendungskonfiguration kontrollieren — Einstellungen → Anwendungskonfiguration → Abschnitt KI: alle Felder leer lassen (die Compose-Datei ist die Wahrheit).
Teil D — Einrichtung in Paperless und am Scanner¶
Task D1: Startschema einspielen¶
-
Step 1: Robin ergänzt seine Absender in
paperless-ngx/seed/schema.yaml(correspondents:), Name wie im Briefkopf, ein Eintrag je Zeile- name: …. Commit auf einem Branchchore/paperless-correspondents, PR, Merge. -
Step 2: Trockenlauf vom Laptop
cd /home/robin/repos/homeserver && git pull --ff-only
PAPERLESS_URL=https://paperless.home.robinwerner.net PAPERLESS_TOKEN="$(cat ~/.paperless-token)" \
uv run paperless-ngx/seed/seed.py --dry-run
Expected: je Abschnitt „0 vorhanden, N fehlen" mit den Namen aus dem Schema
- Step 3: Einspielen, dann Idempotenz beweisen
PAPERLESS_URL=… PAPERLESS_TOKEN=… uv run paperless-ngx/seed/seed.py
PAPERLESS_URL=… PAPERLESS_TOKEN=… uv run paperless-ngx/seed/seed.py
Expected: erster Lauf legt 7 + 14 + 9 + N Objekte an; zweiter Lauf meldet überall „0 fehlen" und 0 Objekte angelegt.
Paperless validiert jede Vorlage beim Anlegen mit einem Dummy-Dokument (ASN 12345). Die Vorlagen nutzen asn | int,
weil Paperless die ASN als Text übergibt (Befund des Abschlussreviews 20.09., in der Jinja-Sandbox verifiziert).
Ein 400 bei storage_paths wäre also ein neuer Fehler: Server-Body aus der Skriptausgabe lesen, nicht raten.
- Step 4: Sichtprüfung in der UI: Tags (Inbox mit Inbox-Häkchen), Dokumenttypen, Speicherpfade (die Vorschau
rendert mit demselben Dummy-Dokument und muss
robin/Arbeit/…/… ASN12345zeigen), Korrespondenten.
Task D2: Workflows (UI, Einstellungen → Workflows)¶
-
W1 „Scan von Robin" — Auslöser Verarbeitung gestartet, Filter Pfad
*/robin/*(Quelle: Consume-Ordner). Aktion Zuweisung: Eigentümerrobin, TagInbox. Reihenfolge 1. -
W2 „KI-Vorschläge anwenden" — Auslöser Dokument hinzugefügt, kein Filter. Aktion KI-Vorschläge anwenden: Felder Titel ✔, Tags ✔, Korrespondent ✔, Dokumenttyp ✔, Speicherpfad ✔, Erstelldatum ✔; Fehlende anlegen: nur Korrespondent; Vorhandene überschreiben: Titel und Erstelldatum. Reihenfolge 2.
-
Prüfen: beide Workflows aktiv, W1 vor W2.
Task D3: Gespeicherte Ansichten (UI, Dokumente → Filter → Ansicht speichern)¶
- „Inbox" — Filter Tag
Inbox; Seitenleiste ✔, Dashboard ✔. - „Handeln" — Filter Tag
Handeln; Seitenleiste ✔, Dashboard ✔. - „Steuer laufendes Jahr" — Filter Tag
Steuerund Erstellt ab01.01.<Jahr>; Seitenleiste ✔. - „Unsortiert" — Filter Speicherpfad ohne; Seitenleiste ✔.
Task D4: Quick Set am HP 4302¶
- Step 1: EWS öffnen (IP des Druckers, PIN von der Klappe oder eigenes Passwort) → Settings → Control Panel Customization → Quick Sets → + → Typ Scan to Network Folder, Name Paperless.
- Step 2: Netzwerkpfad
\\10.10.10.3\paperless, Benutzerscanner+ Passwort (wie „Ablage"). Apply and Test muss grün werden. - Step 3: Datei-Einstellungen: Dateityp PDF, Farbe Farbe, Auflösung 300 dpi, Original beidseitig,
Dateinamen-Präfix
scan. - Step 4: Erster Scan — ein einseitiges Dokument mit ASN-Etikett. Erwartet: die Datei erscheint kurz in
consume/robin/([NUC] ls -ln), verschwindet nach dem Import, das Dokument steht in der Inbox mit gesetzter ASN, Eigentümer Robin, und nach W2 mit Titel, Korrespondent, Typ, Speicherpfad. Prüfen unter Dokumente → Details unddocker logs paperless_ngx --since 10m. - Step 5: Stapeltest — drei Dokumente mit je einem Etikett auf Seite 1 in einem Durchgang. Erwartet: drei Dokumente, drei ASN, richtige Seitenaufteilung.
- Step 6: Nur falls Etiketten nicht erkannt werden (Log: kein
ASNgefunden): in der Compose-DateiPAPERLESS_CONSUMER_BARCODE_DPI: "600"undPAPERLESS_CONSUMER_BARCODE_UPSCALE: "1.5"ergänzen (PR),up -d --force-recreate paperless_ngx, Schritt 4 wiederholen.
Teil E — Mail über Proton Bridge¶
Task E1: Proton vorbereiten (Robin)¶
- Step 1: In Proton Mail ein Label „Paperless" anlegen (Seitenleiste → Labels → +).
- Step 2: Filter für die regelmäßige Post: Einstellungen → Filter → Filter hinzufügen — Bedingung Absender
enthält
<Domain des Versicherers>(je ein Filter oder ein Filter mit mehreren Bedingungen), Aktion Label „Paperless". Kein „Verschieben". Für Einzelfälle setzt Robin das Label von Hand. - Step 3: Postfachgröße notieren (Einstellungen → Konto → Speicher), weil Bridge den ganzen Postkasten lokal
cached (
/mnt/ssd/container-data/paperless/protonmail-bridge).
Task E2: Bridge anmelden [ROBIN/NUC]¶
- Step 1: Interaktive Anmeldung (2FA am Handy bereithalten). Vorher den Run-Container stoppen: Bridge hält
einen Single-Instance-Lock unter
/data, das beide Container teilen; mit laufendemprotonmail-bridgebrichtbridge --climit „running instance is newer than this one" ab.
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml stop protonmail-bridge
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml --profile init run --rm protonmail-bridge-init
Im Bridge-CLI: login → Proton-Adresse, Passwort, 2FA-Code → nach „Account … was added" info → Bridge-Passwort
und Adresse notieren (Passwortmanager) → exit.
- Step 2: Dienst neu starten und prüfen
docker compose -f /opt/rpi-docker-compose/paperless-ngx/docker-compose.yaml up -d protonmail-bridge
sleep 60
docker ps --filter name=protonmail-bridge --format '{{.Names}}\t{{.Status}}'
docker logs protonmail-bridge --since 2m 2>&1 | grep -iE 'sync|error|logged' | tail -10
Expected: protonmail-bridge Up … (healthy); im Log der Beginn der Synchronisation, keine Fehler
- Step 3: IMAP aus dem Paperless-Container erreichbar
docker exec paperless_ngx python3 -c "import socket; s=socket.create_connection(('protonmail-bridge',1243),5); print(s.recv(200)); s.close()"
Expected: eine Zeile, die mit b'* OK beginnt und Proton Mail Bridge enthält
Task E3: Mailkonto und Regel in Paperless (UI, Einstellungen → E-Mail)¶
- Konto „Proton (Bridge)" — IMAP-Server
protonmail-bridge, Port1243, Sicherheit Keine (Verkehr bleibt im Docker-Netz), Benutzername = Proton-Adresse, Passwort = Bridge-Passwort ausinfo. Verbindung testen → grün. - Regel „Label Paperless → Anhänge" — Konto oben, Ordner
Labels/Paperless, Maximales Alter0, Anhangstyp Nur Anhänge, Dateinamensfilter*.pdf, Umfang Nur Anhänge verarbeiten; Zuweisungen: Titel aus Betreff, Korrespondent aus Absender (Name), TagsInbox+E-Mail, Eigentümerrobin. Aktion nach Verarbeitung Verschieben nachLabels/Paperless-erledigt(Label vorher in Proton anlegen; bei Bridge ist das ein Label-Tausch, die Mail bleibt im Posteingang. Eine Aktion „Nichts" gibt es nicht, „Als gelesen" holt nur ungelesene Mails, „Markieren" kollidiert mit Robins Sternen). Maximales Alter0= unbegrenzt. - Test: eine Mail mit PDF-Anhang mit Label „Paperless" versehen; spätestens nach 10 Minuten (oder
Jetzt abrufen) steht das Dokument in der Inbox mit Tag
E-Mail; unter E-Mail → Verarbeitete E-Mails erscheint die Nachricht; die Mail liegt weiter im Posteingang.
Teil F — Betrieb und Abnahme¶
Task F1: Timer und Collector [ROBIN/NUC]¶
- Step 1: Units installieren
cp /opt/rpi-docker-compose/paperless-ngx/host-config/paperless-*.service /opt/rpi-docker-compose/paperless-ngx/host-config/paperless-*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now paperless-export.timer paperless-consume-collector.timer
systemctl list-timers 'paperless-*'
Expected: beide Timer mit nächstem Lauf (Export 01:15, Collector in ≤ 15 min)
- Step 2: Collector einmal von Hand, Wirkung prüfen
systemctl start paperless-consume-collector.service
cat /mnt/ssd/container-data/monitoring-stack/textfile/paperless.prom
docker exec prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=paperless_consume_collector_timestamp_seconds' | head -c 300
Expected: drei Gauges in der Datei; die Prometheus-Antwort enthält "result":[{ mit einem Wert (Metrik hat Serien)
- Step 3: Export einmal von Hand
systemctl start paperless-export.service
ls /mnt/paperless/export
ls /mnt/paperless/export/json | head -3
Expected: archive json manifest.json originals thumbnails; je Dokument eine JSON-Datei
Task F2: Alarmregeln aktivieren [ROBIN/NUC]¶
- Step 1:
monitoring-stackliegt im selben Repo,git pullaus Task C4 hat die Regeln schon geholt. Reload:
docker exec prometheus wget -qO- --post-data='' http://localhost:9090/-/reload; echo "exit=$?"
docker exec prometheus wget -qO- http://localhost:9090/api/v1/rules | grep -o 'PaperlessConsume[A-Za-z]*' | sort -u
Expected: exit=0, Ausgabe PaperlessConsumeCollectorStale und PaperlessConsumeStuck
- Step 2: Gegenprobe, dass die Metrik ausschlägt —
[NUC root](kein Dotfile, die zählt der Collector nicht)
touch -d '-45 minutes' /mnt/ssd/container-data/paperless/consume/robin/alerttest.txt
systemctl start paperless-consume-collector.service
docker exec prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=paperless_consume_stale_files' | grep -o '"value":\[[^]]*\]'
rm /mnt/ssd/container-data/paperless/consume/robin/alerttest.txt
systemctl start paperless-consume-collector.service
Expected: erst "value":[…,"1"], nach dem Löschen wieder "0". Die Datei zwischen den beiden Läufen zügig löschen:
Paperless versucht sonst, sie zu konsumieren, und meldet auf der Aufgaben-Seite einen Fehler (was den Zulauf ebenfalls
belegt, aber Aufräumen kostet).
Task F3: Kuma-Monitor (Robin, VPS-UI)¶
- Uptime Kuma → Neuer Monitor → HTTP(s), Name „Paperless", URL
https://paperless.home.robinwerner.net/, Intervall 60 s, erwarteter Status 200. Grün nach dem ersten Check.
Task F4: Nachtlauf-Nachweis (am Tag nach Teil C)¶
-
[NUC root]
/root/.local/bin/borgmatic list --archive latest --path mnt/paperless/export | head -5
/root/.local/bin/borgmatic list --archive latest --path var/lib/db-dumps | grep -i paperless
journalctl -u paperless-export.service --since yesterday | tail -3
Expected: Export-Pfade im Archiv, postgres-paperless_db.sql.gz im Dump-Verzeichnis, Export-Lauf 01:15 „Finished"
Task F5: Modell-Mini-Turnier (Spec 7.3)¶
- Step 1: Fünf frisch importierte Dokumente wählen (Rechnung, Schreiben, Bescheid, Vertrag, Zeugnis). Mit
brain-llamacpp(Compose-Stand) je Dokument Vorschlagen klicken, notieren: Titel brauchbar (j/n), Korrespondent richtig, Typ richtig, Speicherpfad richtig, Dauer bis zur Antwort. - Step 2: Einstellungen → Anwendungskonfiguration → KI → Modell
json-llamacpp(vorübergehend), dieselben fünf Dokumente erneut Vorschlagen, gleiche Notizen. - Step 3: Feld wieder leeren (Compose bleibt die Wahrheit). Sieger nach Treffern je Feld, Latenz als
Zweitkriterium. Verliert
brain-llamacpp, Modell in der Compose-Datei per PR tauschen. Ergebnis als Tabelle ins Umsetzungs-Log.
Task F6: Abnahme (Spec 11) und OCR-Schwelle (Spec 7.4)¶
- 20 echte Dokumente über Quick Set „Paperless", als Stapel mit Etiketten. Zählen: Trennung richtig, ASN
gesetzt, wie viele ohne Handkorrektur richtig in Korrespondent/Typ/Speicherpfad (Ziel ≥ 15; gilt für den zweiten
Stapel, der erste wird von Hand eingeordnet und der Index neu gebaut, Spec 7.2). Dazu auf der
Platte:
[NUC] ls -R /mnt/paperless/media/documents/archive/robin/ | head -40zeigt den Ordnerbaum aus der Spec, und im Wurzelverzeichnis vonarchive/liegt keine0000042.pdf-artige Datei (das wäre der stille Rückfall, wenn eine Vorlage nicht rendert; die UI-Felder sähen trotzdem korrekt aus). - OCR-Schwelle: je Dokument einen Betrag oder ein Aktenzeichen über die Volltextsuche suchen. Unbrauchbar bei ≥ 2 von 20 → paperless-gpt nur für OCR nach E7b; sonst bleibt Tesseract. Ergebnis ins Log.
- Mail: eine Mail mit PDF-Anhang landet automatisch (Task E3 bestanden).
- Chat: Frage zu einem Dokument im Chat → Antwort mit Quellenlink (Index nach 03:30 vorhanden; Indexstand:
docker exec paperless_ngx document_llmindex update). - Metriken/Monitor:
paperless_consume_stale_fileshat Serien, Kuma grün, Export im Archiv (F4).
Task F7: Dokumentation, Issues, Memory¶
-
homelab-documentation/docs/projects/homeserver/productivity/paperless-ngx.mdauf den E7-Stand bringen (Stack-Tabelle, Zulauf Papier/Mail, Ordnerlogik, KI, Backup, Host-Units — Inhalt wiepaperless-ngx/docs.md, dazu Link auf Design und Plan). Branch, PR, Merge. - Umsetzungs-Log je Session ergänzen (oben, Format wie die bestehenden Einträge): erledigte Tasks, Turnier- und Schwellenergebnis, Funde.
- Issue #190 kommentieren (deutsch, Struktur: gebaut / Abweichungen / verschoben / Links). #87 schließen mit Verweis auf #190 (native KI statt Ollama-Sidecar). #242 bleibt offen für E7b.
- E7b-Issue anlegen: Dropbox-Spiegel, Restore-Probe, Lars/Eigentümertrennung, Vision-OCR (falls Schwelle), „Mail als PDF", Custom Fields.
- Memory
homelab-roadmap-2026-08.md: E7-Abschnitt auf „gebaut" mit den Fakten, die man sonst neu suchen müsste (Bridge-Port 1243, Token-Pfad, Modell-Sieger, Schwellenergebnis).
Self-Review¶
Spec-Abdeckung: Abschnitt 3 Architektur → A3, C2, C4 · 4.1 Samba → B2, C3 · 4.2 Quick Sets → D4 · 4.3 ASN →
A3, D4 · 4.4 Consumer/W1 → A3, D2 · 5 Ordnerlogik → A3 (FILENAME_FORMAT), B6 (storage_paths) · 6 Schema,
Workflows, Ansichten → B6, D1, D2, D3 · 7 KI → A3, C5, F5, F6 · 8 Mail → A3 (Bridge), E1–E3 · 9 Secrets/v3 →
A2, A3, A4, C1 · 10 Backup/Betrieb → B3, B4, B5, F1, F2, F3, F4 · 11 Abnahme → F6 · 12 E7b → F7 (Issue) ·
13 Prüfpunkte → Image-Tags (Constraints), Bridge-Image (A3), format-Filter (D1 Schritt 3), Barcode-DPI (D4
Schritt 6), SMB1-Clients (C3 Schritt 1), Postfachgröße (E1 Schritt 3), Nachtlauf (F4).
Platzhalter: keine offenen TBD; einzige bewusst leere Stelle ist correspondents: [] in schema.yaml, die Robin
in D1 Schritt 1 füllt (Daten, nicht Design).
Änderungen nach dem Abschlussreview (20.09.): Der Prüfpunkt „format-Filter" aus Spec 13 ist geklärt — der
Filter existiert, aber Paperless übergibt asn als Text, deshalb überall format(asn | int) (A3, B6). Die
Collector-Unit braucht SupplementaryGroups=paperless, weil der Consume-Baum 0770 ist (B4). Die Bridge-Anmeldung
stoppt vorher den Run-Container (A5, E2). Die include-Zeile steht als letzte Zeile von [global], C3 prüft das
mit grep -i 'global parameter'. PAPERLESS_TRUSTED_PROXIES ist ein Präfix (10.100.), kein CIDR (A3). Die
Abnahme prüft den Ordnerbaum auf der Platte (F6). Ein siebter Seed-Test deckt den 400-Pfad von create_items ab (B6).
Konsistenz: Container-Namen paperless_ngx, paperless_db, paperless_redis, gotenberg, tika,
protonmail-bridge, protonmail-bridge-init in A3, A5, C4, E2, F1 identisch · Bridge-Port 1243 in A3, A5, E2, E3 ·
Consume-Pfad …/consume/robin in A3, B2, C2, C3, D4 · Textfile-Datei paperless.prom in A5, B4, F1 · Metriknamen in
B4 und B5 gleich · Infisical-Keys in A2, A5, C1 gleich · Timer-Namen in A5, B3, B4, F1 gleich.