Files
homelab/kubernetes/apps/paperless/paperless.yaml
security-bot 68c07963fa
ci/woodpecker/cron/alert-monitor Pipeline was successful
chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.3.2
Updates managed by image-automation-controller.
2026-10-16 06:42:45 +00:00

382 lines
16 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 Mistral AI's hosted API (OpenAI-compatible). Document
# text is sent to Mistral. The key is PAPERLESS_AI_LLM_API_KEY, from the
# paperless-secrets Secret (OpenBao apps/paperless, field minstral-token).
#
# 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: "https://api.mistral.ai/v1"
PAPERLESS_AI_LLM_MODEL: "mistral-small-2603"
# The embedding endpoint is the in-cluster llama-embed, a ClusterIP address,
# and paperless blocks non-public endpoints unless this is set. Default is
# already true; explicit because the embedding integration silently stops
# working if it ever flips.
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
# 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"
# The embedding key falls back to PAPERLESS_AI_LLM_API_KEY when unset, which
# would send the Mistral token to llama-embed. llama-embed has no auth.
PAPERLESS_AI_LLM_EMBEDDING_API_KEY: "unused"
# 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.3.2 # {"$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_AI_LLM_API_KEY
valueFrom:
secretKeyRef:
name: paperless-secrets
key: minstral-token
- 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.1.3@sha256:fb3862c949af7c79996d0e514ee96128644d840835ed5368eeecb14a869de361
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
---
# Canonical paperless.renner.uno, gated by authentik-gate-sync (ingress class
# authentik-gate). The docs.renner.uno alias lives in
# docs-traefik-ingress.yaml. The static-asset tree passes anonymously because
# the manifest is fetched without credentials; /api/documents/* stays gated.
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
auth.renner.uno/group: Paperless Users
auth.renner.uno/authorization-flow: paperless-authorization
auth.renner.uno/skip-path: ^/static/.*
spec:
ingressClassName: authentik-gate
rules:
- host: paperless.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: paperless
port:
number: 80
tls:
- secretName: paperless-tls
hosts:
- paperless.renner.uno