Files
homelab/kubernetes/apps/paperless/paperless.yaml
T
atlas-botandClaude Opus 5.5 21a7583523
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ci: cite YAML by anchor instead of line number, and guard it (REN-183)
Comments that pointed at other YAML as `file.yaml:N` went stale on any
edit above the cited line; three drift fixes had been merged by hand
(REN-181). The cited block now carries `# anchor: <name>` and the
comment cites `path.yaml#<name>`.

- Sweep: all 32 line-number citations in YAML comments converted.
  Several were already stale at head and now point at the right block
  (see PR). Vendored files (Flux gotk-components, IMC CRD) get no
  anchor; those two citations name the object/schema path instead.
- Guard: scripts/ci/yaml-citations.sh in ci-manifests-test.yml; fails
  on a line-number citation or a path#name whose file/anchor is gone.
  Self-test in ci-scripts-test.yml.
- CONTRIBUTING.md: the convention in three lines.

Comment-only under kubernetes/: every file parses to identical objects
before and after, so Flux applies nothing and no ConfigMap data changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:44:54 +00:00

403 lines
17 KiB
YAML

apiVersion: v1
kind: ConfigMap
metadata:
name: paperless-config
namespace: paperless
data:
# Identity comes from the Authentik gate outpost via the
# X-Authentik-Username request header (set by the outpost in proxy
# mode). Paperless's HTTP_REMOTE_USER auth backend auto-creates the
# user on first request and treats the gate as the source of truth.
# Both flags are required: ENABLE_HTTP_REMOTE_USER covers the Django
# views (web UI), ENABLE_HTTP_REMOTE_USER_API covers DRF endpoints
# under /api/ (used by the SPA frontend after login). Without the
# API flag the SPA gets 401/403 on /api/ui_settings/, /api/saved_views/
# etc. immediately after login.
PAPERLESS_ENABLE_HTTP_REMOTE_USER: "true"
PAPERLESS_ENABLE_HTTP_REMOTE_USER_API: "true"
PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME: "HTTP_X_AUTHENTIK_USERNAME"
# Auto-created paperless users (REMOTE_USER auth on first login)
# land in the `Familie` group, which already carries the
# permissions the SPA needs (view_uisettings etc.). Without this,
# new users hit 403 on /api/ui_settings/ until an admin grants
# perms manually.
PAPERLESS_ACCOUNT_DEFAULT_GROUPS: "Familie"
PAPERLESS_URL: "https://paperless.renner.uno"
PAPERLESS_CSRF_TRUSTED_ORIGINS: "https://paperless.renner.uno,https://docs.renner.uno"
# anchor: allowed-hosts
PAPERLESS_ALLOWED_HOSTS: "*"
PAPERLESS_TIME_ZONE: "Europe/Berlin"
PAPERLESS_OCR_LANGUAGE: "deu+eng"
# Default is cpu_count / TASK_WORKERS, 20 on k8s-1; OCR memory scales with it.
PAPERLESS_THREADS_PER_WORKER: "5"
PAPERLESS_CONSUMER_RECURSIVE: "true"
PAPERLESS_CONSUMER_DELETE_DUPLICATES: "true"
PAPERLESS_CONSUMER_ENABLE_BARCODES: "true"
PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE: "true"
# No CONSUMER_BARCODE_SCANNER: zxing-cpp is the only backend, so the
# setting no longer exists. pyzbar and libzbar are gone from the image.
#
# CephFS does not propagate inotify across nodes — files written
# via the RWX consume PVC from a pod on a different node than
# paperless never fire IN_CREATE in paperless's kernel. Polling
# is the reliable backstop. See wiki: cephfs-inotify-no-cross-node-propagation.
PAPERLESS_CONSUMER_POLLING_INTERVAL: "10"
PAPERLESS_REDIS: "redis://redis:6379"
PAPERLESS_DBENGINE: "postgresql"
# anchor: db-host
PAPERLESS_DBHOST: "pprl-pg-rw"
PAPERLESS_DBNAME: "paperless"
PAPERLESS_TIKA_ENABLED: "1"
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
# AI suggestions against the cluster's own LLM (kubernetes/apps/llama-server).
# Nothing leaves the cluster: both endpoints are ClusterIP Services, so
# document text is never sent to a third party and there is no API key to
# rotate — llama-server has no authentication, and the ai namespace's network
# policy is what restricts who may reach it.
#
# CAVEAT, and it is a real one: paperless reads these from an
# ApplicationConfiguration row in the database and falls back to the
# environment only when that row is empty. A value set in the admin UI
# therefore overrides this ConfigMap silently. Configure AI in ONE place —
# here — and leave those fields untouched in the UI.
PAPERLESS_AI_ENABLED: "true"
PAPERLESS_AI_LLM_BACKEND: "openai-like"
PAPERLESS_AI_LLM_ENDPOINT: "http://llama-server.ai.svc.cluster.local:8080/v1"
# Must match --alias in kubernetes/apps/llama-server/deployment.yaml. The
# server ignores the field on the wire, but paperless sends it and the value
# is what appears in its logs.
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
# ClusterIP addresses are private, and paperless blocks non-public endpoints
# unless this is set. Default is already true; explicit because the whole
# integration silently stops working if it ever flips.
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
# Under llama-server's own 16384, leaving room for the response.
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
# The 120s default is too tight at ~11 tok/s: a classification is roughly
# 2-4k prompt tokens plus a second round trip for localisation.
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
# Matched against Django's settings.LANGUAGES, so the region is required —
# "de" alone does not resolve. Without it the model returns German tags but
# English document types, which was reproducible against this endpoint.
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
# RAG embeddings from llama-embed. Its Qwen3-Embedding covers 100+ languages,
# where paperless's default all-MiniLM-L6-v2 is documented as suited to
# primarily English archives — the better fit for a German corpus.
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
# Must match --alias in kubernetes/apps/llama-server/embed.yaml. Changing the
# embedding model invalidates every stored vector, so a swap means the LLM
# index has to be rebuilt.
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "bge-m3"
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless
namespace: paperless
labels:
app.kubernetes.io/name: paperless
annotations:
# Reloader restarts paperless when paperless-secrets changes (e.g. after a
# secret-key rotation). See kubernetes/infrastructure/openbao/rotation/paperless.yaml.
secret.reloader.stakater.com/reload: paperless-secrets
# And when paperless-config changes. The ConfigMap is consumed with
# envFrom, and Kubernetes does not restart a pod when a ConfigMap it reads
# that way is updated — the running container keeps the environment it
# started with. Without this, a settings change applies cleanly in git and
# in the cluster while the application never sees it, which is how the AI
# settings landed with no effect at all.
configmap.reloader.stakater.com/reload: paperless-config
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless
template:
metadata:
labels:
app.kubernetes.io/name: paperless
spec:
enableServiceLinks: false
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
# Bridge auth dance: ProtonMail Bridge serves IMAP with a self-
# signed cert whose only SAN is IP:127.0.0.1, so any non-localhost
# client fails hostname verification (paperless's IMAP path uses
# ssl.create_default_context() — no opt-out, see
# paperless_mail/mail.py:415). The init container fetches the
# live bridge cert into a shared volume, the socat sidecar
# exposes 127.0.0.1:11143 → protonmail-bridge:143 in the pod
# network namespace, and paperless's MailAccount points at
# 127.0.0.1:11143 with PAPERLESS_EMAIL_CERTIFICATE_LOCATION
# pointing at the captured cert. Result: hostname check passes
# (paperless connects to literal 127.0.0.1), CA pin restores
# trust without disabling verification.
initContainers:
- name: fetch-bridge-cert
# alpine + the openssl CLI, built by .woodpecker/build-ci-images.yml
# from ci/images/alpine-openssl/. Replaces `apk add --no-cache openssl`
# on every paperless pod start: tools belong in the image, not
# installed at runtime. Dated tag so a change is a reviewable diff
# here, matching the ci-ansible / alpine-jq convention.
#
# konairius/, not gitea_admin/, and that is not cosmetic. This is an
# INIT container, so paperless does not start without it — and during
# a cold rebuild gitea.renner.uno does not exist, because Gitea runs
# inside the cluster being rebuilt. A rehearsal left this pod in
# Init:ErrImagePull on exactly this image.
#
# The node falls back to a ghcr.io copy in that window
# (machine.registries.mirrors, applied by bootstrap.sh). Containerd
# keeps the repository path when it falls through, so the path has to
# exist on both registries — hence konairius/, which matches the ghcr
# namespace. Steady state still pulls from Gitea and never leaves the
# network.
image: gitea.renner.uno/konairius/alpine-openssl:2026-09-04
command:
- sh
- -c
- |
set -e
# Wait until the bridge presents a cert via STARTTLS.
# Re-fetched on every paperless restart so a bridge cert
# rotation only requires a paperless rollout to pick up.
until openssl s_client -connect protonmail-bridge:143 -starttls imap -showcerts </dev/null 2>/dev/null \
| sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' \
> /shared/bridge.pem && [ -s /shared/bridge.pem ]; do
echo "waiting for bridge IMAP STARTTLS..."
sleep 2
done
echo "fetched bridge cert:"
openssl x509 -in /shared/bridge.pem -noout -subject -issuer -dates
volumeMounts:
- name: bridge-cert
mountPath: /shared
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.2.1 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
envFrom:
- configMapRef:
name: paperless-config
env:
- name: PAPERLESS_SECRET_KEY
valueFrom:
secretKeyRef:
name: paperless-secrets
key: secret-key
- name: PAPERLESS_DBUSER
valueFrom:
secretKeyRef:
name: pprl-pg-app
key: username
- name: PAPERLESS_DBPASS
valueFrom:
secretKeyRef:
name: pprl-pg-app
key: password
- name: PAPERLESS_EMAIL_CERTIFICATE_LOCATION
value: /shared/bridge.pem
volumeMounts:
- name: data
mountPath: /usr/src/paperless/data
- name: media
mountPath: /usr/src/paperless/media
- name: consume
mountPath: /usr/src/paperless/consume
- name: bridge-cert
mountPath: /shared
readOnly: true
# /accounts/login/ rather than /api/ or a TCP connect.
#
# /api/ is unusable as a probe target: with ENABLE_HTTP_REMOTE_USER
# the remote-user middleware logs "No authentication provided from
# private IP …" for every unauthenticated API request, so probing it
# floods `paperless.auth`. Measured: four requests to /api/ produce
# four log lines, four to /accounts/login/ produce none — the
# middleware only covers the API surface.
#
# A TCP connect avoids the noise but only proves gunicorn accepted a
# socket. /accounts/login/ renders a real Django view, so URL
# routing, settings and templating all have to work. It does not
# prove Postgres is healthy; nothing cheap here does.
#
# Safe against ALLOWED_HOSTS: the kubelet sends the pod IP as Host,
# and PAPERLESS_ALLOWED_HOSTS is "*". Narrowing that value later
# means giving these probes an explicit Host header.
# The container does not serve until its init sequence finishes:
# s6 makes svc-webserver depend on init-migrations and
# init-search-index. On the first 3.x start those are the SHA256
# checksum backfill, which opens and reads every original and every
# archive file, and a full Tantivy index build, since the Whoosh
# index cannot be converted. Both scale with the corpus.
#
# startupProbe holds liveness and readiness off until the webserver
# answers, giving that boot 30 * 20s = 10 minutes. Without it the
# 120s liveness delay plus three 30s periods kills the pod 210s in,
# part-way through a migration, and the next boot starts over.
# anchor: http-probes
startupProbe:
httpGet:
path: /accounts/login/
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /accounts/login/
port: http
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /accounts/login/
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 4Gi
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
capabilities:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
# anchor: bridge-tls-proxy
- name: bridge-tls-proxy
image: alpine/socat:1.8.0.3
args:
- "-d"
- "TCP-LISTEN:11143,fork,reuseaddr,bind=127.0.0.1"
- "TCP:protonmail-bridge:143"
resources:
requests:
cpu: 5m
memory: 8Mi
limits:
memory: 32Mi
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
volumes:
# `data` is RWO on ceph-block, not RWX on ceph-filesystem. Only this
# Deployment mounts it and its contents are single-writer state — the
# search index, classification model, celerybeat schedule, logs. On
# CephFS every access went through the MDS: 22 requests/sec sustained
# with zero CI running, ~15 journal events/sec against 2 directory
# commits per 180s, i.e. cap/inode churn rather than file writes. That
# was most of the constant 7-8 GB/day on cephfs-metadata, a size 3 pool.
#
# `consume` and `media` below STAY on CephFS: the ftp-scanner writes to
# those from another node, so they genuinely need RWX.
- name: data
persistentVolumeClaim:
claimName: paperless-data-block
- name: media
persistentVolumeClaim:
claimName: paperless-media
- name: consume
persistentVolumeClaim:
claimName: paperless-consume
- name: bridge-cert
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
metadata:
name: paperless
namespace: paperless
spec:
selector:
app.kubernetes.io/name: paperless
ports:
- port: 80
targetPort: http
---
# ExternalName mirror so the paperless-namespace Ingress can backend to
# the gate outpost in the authentik namespace (cross-namespace Service
# refs aren't supported in standard Ingress).
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-paperless-gate
namespace: paperless
spec:
type: ExternalName
externalName: ak-outpost-paperless-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
---
# Canonical paperless.renner.uno on Traefik. The docs.renner.uno alias
# lives in docs-traefik-ingress.yaml with its own RedirectRegex
# Middleware in place of the previous nginx configuration-snippet
# host-redirect. Traefik does not honour the nginx-only proxy-body-size
# annotation; the controller streams requests without an upper bound by
# default, so the 100m raise that paperless's uploads needed under
# nginx is automatic here.
#
# The gate provider stays in `mode: proxy`: the Authentik outpost
# terminates the TLS-decrypted request, validates the session cookie,
# and forwards to paperless. Traefik never sees paperless's listener —
# its only route is the gate outpost's ExternalName service, which
# resolves into the authentik namespace.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperless
namespace: paperless
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
rules:
- host: paperless.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ak-outpost-paperless-gate
port:
number: 9000
tls:
- secretName: paperless-tls
hosts:
- paperless.renner.uno