feat(paperless): deploy Paperless-ngx on shared PostgreSQL
ci / Compose (pull_request) Canceled after 0s
ci / Workflows (pull_request) Canceled after 0s
ci / Shell (pull_request) Canceled after 0s
ci / Formatting (pull_request) Canceled after 0s
ci / Python and tests (pull_request) Canceled after 0s
ci / YAML (pull_request) Canceled after 0s
ci / Dockerfiles (pull_request) Canceled after 0s
ci / Kubernetes (pull_request) Canceled after 0s
ci / image-plan (pull_request) Canceled after 0s
ci / Image (${{ matrix.name }}) (pull_request) Canceled after 0s
ci / build (pull_request) Canceled after 0s

This commit is contained in:
forust committed 2026-10-08 00:32:54 +02:00
1 parent 4c7c53e0f2
commit fb46fb48b5
14 files changed
+455 -4

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
# Paperless-ngx
Paperless-ngx runs in the `paperless` namespace. It uses the shared PostgreSQL
service in the `database` namespace and Valkey for its task queue. The document
library, exports, and consume folder are stored on the `local-path-retain`
volume. The PVC size is fixed at 50 GiB because this storage class does not
support volume expansion.
The local route is `https://papers.workstation.internal`; the public route is
`https://papers.forust.xyz`. Both use TLS. Paperless keeps its own login and
password authentication. OCR is configured for Russian and English documents.
Keep `papers.forust.xyz` in the existing Cloudflare DDNS `DOMAINS` setting so
the public record follows the workstation address.
## Prepare the secret
Create `k8s/secrets.yaml` on the workstation from
`k8s/secrets.yaml.example`. Set a unique random `PAPERLESS_SECRET_KEY`, a long
`PAPERLESS_ADMIN_PASSWORD`, and `PAPERLESS_DB_PASSWORD`.
Add the same `PAPERLESS_DB_PASSWORD` value to the local
`postgres/k8s/secrets.yaml` file. Keep both secret files out of Git. The
database bootstrap Job creates the `paperless` role and database from the
shared PostgreSQL secret. The job runs in the `database` namespace and needs
that namespace's existing `postgres-shared-secrets` Secret.
For example, generate a key with:
```sh
python3 -c 'import secrets; print(secrets.token_urlsafe(64))'
```
Then apply the secret before enabling the service:
```sh
kubectl apply -f paperless/k8s/namespace.yaml
kubectl apply -f postgres/k8s/secrets.yaml
kubectl apply -f paperless/k8s/secrets.yaml
```
The normal deploy workflow applies the remaining manifests when
`paperless/k8s/active` is present. Verify the rollout and ingress after deploy:
```sh
kubectl -n paperless rollout status deployment/paperless
kubectl -n paperless get pods,pvc,services
```
Back up the `paperless-data` PVC and the shared PostgreSQL database. The PVC
contains the originals, archived PDFs, and export/consume folders. Valkey has
no persistent volume; queued tasks are recreated after a restart.
+1
View File
@@ -0,0 +1 @@
+28
View File
@@ -0,0 +1,28 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: paperless-prod-tls
namespace: paperless
spec:
secretName: paperless-prod-tls
dnsNames:
- papers.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: paperless
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+58
View File
@@ -0,0 +1,58 @@
apiVersion: batch/v1
kind: Job
metadata:
name: paperless-database-init
namespace: database
spec:
backoffLimit: 5
template:
metadata:
labels:
app.kubernetes.io/name: paperless-database-init
spec:
restartPolicy: OnFailure
containers:
- name: create-database
image: postgres:17.11-alpine
command:
- /bin/sh
- -ec
- |
PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql \
--host postgres \
--username postgres \
--dbname postgres \
--set ON_ERROR_STOP=1 \
--set paperless_password="$PAPERLESS_DB_PASSWORD" <<'SQL'
SELECT format(
'CREATE ROLE paperless LOGIN PASSWORD %L',
:'paperless_password'
)
WHERE NOT EXISTS (
SELECT FROM pg_roles WHERE rolname = 'paperless'
)
\gexec
SELECT format('CREATE DATABASE paperless OWNER paperless')
WHERE NOT EXISTS (
SELECT FROM pg_database WHERE datname = 'paperless'
)
\gexec
SQL
env:
- name: POSTGRES_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-shared-secrets
key: POSTGRES_ADMIN_PASSWORD
- name: PAPERLESS_DB_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-shared-secrets
key: PAPERLESS_DB_PASSWORD
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
+33
View File
@@ -0,0 +1,33 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: paperless-prod
namespace: paperless
spec:
entryPoints:
- websecure
routes:
- match: Host(`papers.forust.xyz`)
kind: Rule
services:
- name: paperless
port: 8000
tls:
secretName: paperless-prod-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: paperless-local
namespace: paperless
spec:
entryPoints:
- websecure
routes:
- match: Host(`papers.workstation.internal`)
kind: Rule
services:
- name: paperless
port: 8000
tls:
secretName: internal-wildcard-tls
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: paperless
+39
View File
@@ -0,0 +1,39 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: paperless-ingress
namespace: paperless
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: paperless
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik
ports:
- protocol: TCP
port: 8000
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: paperless-valkey-ingress
namespace: paperless
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: paperless-valkey
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: paperless
ports:
- protocol: TCP
port: 6379
+210
View File
@@ -0,0 +1,210 @@
apiVersion: v1
kind: Service
metadata:
name: paperless
namespace: paperless
labels:
app.kubernetes.io/name: paperless
spec:
selector:
app.kubernetes.io/name: paperless
ports:
- name: http
port: 8000
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless
namespace: paperless
labels:
app.kubernetes.io/name: paperless
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless
template:
metadata:
labels:
app.kubernetes.io/name: paperless
spec:
enableServiceLinks: false
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.2.1
ports:
- name: http
containerPort: 8000
env:
- name: PAPERLESS_URL
value: https://papers.forust.xyz
- name: PAPERLESS_ALLOWED_HOSTS
value: papers.forust.xyz,papers.workstation.internal
- name: PAPERLESS_CSRF_TRUSTED_ORIGINS
value: https://papers.forust.xyz,https://papers.workstation.internal
- name: PAPERLESS_TIME_ZONE
value: Europe/Bratislava
- name: PAPERLESS_REDIS
value: redis://paperless-valkey:6379
- name: PAPERLESS_DBENGINE
value: postgresql
- name: PAPERLESS_DBHOST
value: postgres.database.svc.cluster.local
- name: PAPERLESS_DBNAME
value: paperless
- name: PAPERLESS_DBUSER
value: paperless
- name: PAPERLESS_DBPASS
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_DB_PASSWORD
- name: PAPERLESS_OCR_LANGUAGE
value: rus+eng
- name: PAPERLESS_OCR_LANGUAGES
value: rus
- name: PAPERLESS_TASK_WORKERS
value: "1"
- name: PAPERLESS_ADMIN_USER
value: admin
- name: PAPERLESS_SECRET_KEY
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_SECRET_KEY
- name: PAPERLESS_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_ADMIN_PASSWORD
volumeMounts:
- name: documents
mountPath: /usr/src/paperless/data
subPath: data
- name: documents
mountPath: /usr/src/paperless/media
subPath: media
- name: documents
mountPath: /usr/src/paperless/export
subPath: export
- name: documents
mountPath: /usr/src/paperless/consume
subPath: consume
startupProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
failureThreshold: 60
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
periodSeconds: 30
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
volumes:
- name: documents
persistentVolumeClaim:
claimName: paperless-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-data
namespace: paperless
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path-retain
resources:
requests:
storage: 50Gi
---
apiVersion: v1
kind: Service
metadata:
name: paperless-valkey
namespace: paperless
labels:
app.kubernetes.io/name: paperless-valkey
spec:
selector:
app.kubernetes.io/name: paperless-valkey
ports:
- name: redis
port: 6379
targetPort: redis
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless-valkey
namespace: paperless
labels:
app.kubernetes.io/name: paperless-valkey
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless-valkey
template:
metadata:
labels:
app.kubernetes.io/name: paperless-valkey
spec:
containers:
- name: valkey
image: valkey/valkey:9.0.3-alpine
args:
- valkey-server
- --save
- ""
- --appendonly
- "no"
ports:
- name: redis
containerPort: 6379
readinessProbe:
exec:
command: ["valkey-cli", "ping"]
periodSeconds: 10
livenessProbe:
exec:
command: ["valkey-cli", "ping"]
periodSeconds: 30
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: Secret
metadata:
name: paperless-secrets
namespace: paperless
type: Opaque
stringData:
PAPERLESS_SECRET_KEY: "<GENERATE_WITH_python3_-c_import_secrets;_print(secrets.token_urlsafe(64))>"
PAPERLESS_ADMIN_PASSWORD: "<SET_A_LONG_UNIQUE_PASSWORD>"
PAPERLESS_DB_PASSWORD: "<SET_THE_SAME_VALUE_AS_database_PAPERLESS_DB_PASSWORD>"
+1
View File
@@ -4,4 +4,5 @@ GITEA_DB_PASSWORD=
NETBOX_DB_PASSWORD= NETBOX_DB_PASSWORD=
NETRONOME_DB_PASSWORD= NETRONOME_DB_PASSWORD=
PENPOT_DB_PASSWORD= PENPOT_DB_PASSWORD=
PAPERLESS_DB_PASSWORD=
STATUSPAGE_DB_PASSWORD= STATUSPAGE_DB_PASSWORD=
+5 -4
View File
@@ -1,10 +1,10 @@
# Shared PostgreSQL # Shared PostgreSQL
This directory contains the shared PostgreSQL 17 deployment for Authentik, This directory contains the shared PostgreSQL 17 deployment for Authentik,
Gitea, NetBox, Netronome, and Statuspage. It creates one database and one login role Gitea, NetBox, Netronome, Paperless-ngx, and Statuspage. It creates one database
per service. Per-service standalone databases were removed after the and one login role per service. Per-service standalone databases were removed
migration (Sep 2026); Penpot stays on its own compose PostgreSQL (archived, after the migration (Sep 2026); Penpot stays on its own compose PostgreSQL
not part of the shared instance). (archived, not part of the shared instance).
## Compatibility baseline ## Compatibility baseline
@@ -14,6 +14,7 @@ not part of the shared instance).
| Gitea | 1.27.3 | Supported (Gitea requires 12+) | | Gitea | 1.27.3 | Supported (Gitea requires 12+) |
| NetBox | 4.7.x | Supported (NetBox 4.x requires 13+) | | NetBox | 4.7.x | Supported (NetBox 4.x requires 13+) |
| Netronome | 0.14.0 | Supported (upstream's example uses 17) | | Netronome | 0.14.0 | Supported (upstream's example uses 17) |
| Paperless-ngx | 3.2.1 | Supported |
| Statuspage | custom | Supported | | Statuspage | custom | Supported |
A major-version change must use a logical dump/restore; changing only the A major-version change must use a logical dump/restore; changing only the
+12
View File
@@ -26,6 +26,18 @@ spec:
- namespaceSelector: - namespaceSelector:
matchLabels: matchLabels:
kubernetes.io/metadata.name: penpot kubernetes.io/metadata.name: penpot
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: paperless
podSelector:
matchLabels:
app.kubernetes.io/name: paperless
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: database
podSelector:
matchLabels:
app.kubernetes.io/name: paperless-database-init
- namespaceSelector: - namespaceSelector:
matchLabels: matchLabels:
kubernetes.io/metadata.name: statuspage kubernetes.io/metadata.name: statuspage
+2
View File
@@ -126,6 +126,7 @@ data:
: "${NETBOX_DB_PASSWORD:?NETBOX_DB_PASSWORD is required}" : "${NETBOX_DB_PASSWORD:?NETBOX_DB_PASSWORD is required}"
: "${NETRONOME_DB_PASSWORD:?NETRONOME_DB_PASSWORD is required}" : "${NETRONOME_DB_PASSWORD:?NETRONOME_DB_PASSWORD is required}"
: "${PENPOT_DB_PASSWORD:?PENPOT_DB_PASSWORD is required}" : "${PENPOT_DB_PASSWORD:?PENPOT_DB_PASSWORD is required}"
: "${PAPERLESS_DB_PASSWORD:?PAPERLESS_DB_PASSWORD is required}"
: "${STATUSPAGE_DB_PASSWORD:?STATUSPAGE_DB_PASSWORD is required}" : "${STATUSPAGE_DB_PASSWORD:?STATUSPAGE_DB_PASSWORD is required}"
create_role_and_database() { create_role_and_database() {
@@ -147,4 +148,5 @@ data:
create_role_and_database netbox netbox "$NETBOX_DB_PASSWORD" create_role_and_database netbox netbox "$NETBOX_DB_PASSWORD"
create_role_and_database netronome netronome "$NETRONOME_DB_PASSWORD" create_role_and_database netronome netronome "$NETRONOME_DB_PASSWORD"
create_role_and_database penpot penpot "$PENPOT_DB_PASSWORD" create_role_and_database penpot penpot "$PENPOT_DB_PASSWORD"
create_role_and_database paperless paperless "$PAPERLESS_DB_PASSWORD"
create_role_and_database statuspage statuspage "$STATUSPAGE_DB_PASSWORD" create_role_and_database statuspage statuspage "$STATUSPAGE_DB_PASSWORD"
+1
View File
@@ -11,4 +11,5 @@ stringData:
NETBOX_DB_PASSWORD: "" NETBOX_DB_PASSWORD: ""
NETRONOME_DB_PASSWORD: "" NETRONOME_DB_PASSWORD: ""
PENPOT_DB_PASSWORD: "" PENPOT_DB_PASSWORD: ""
PAPERLESS_DB_PASSWORD: ""
STATUSPAGE_DB_PASSWORD: "" STATUSPAGE_DB_PASSWORD: ""