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>
403 lines
17 KiB
YAML
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
|