From 8fdf461ae148703eb88330d71e7ce21f4b3f240a Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Mon, 17 Aug 2026 16:32:13 +0200 Subject: [PATCH] feat(stacks): migrate paperless --- stacks/paperless.opytex.org/.gitignore | 6 + stacks/paperless.opytex.org/README.md | 121 ++++++++++++++++++ .../paperless.opytex.org/docker-compose.yml | 117 +++++++++++++++++ stacks/paperless.opytex.org/env_file | 54 ++++++++ stacks/paperless.opytex.org/secrets.env.yaml | 28 ++++ 5 files changed, 326 insertions(+) create mode 100644 stacks/paperless.opytex.org/.gitignore create mode 100644 stacks/paperless.opytex.org/README.md create mode 100644 stacks/paperless.opytex.org/docker-compose.yml create mode 100644 stacks/paperless.opytex.org/env_file create mode 100644 stacks/paperless.opytex.org/secrets.env.yaml diff --git a/stacks/paperless.opytex.org/.gitignore b/stacks/paperless.opytex.org/.gitignore new file mode 100644 index 0000000..88bfb07 --- /dev/null +++ b/stacks/paperless.opytex.org/.gitignore @@ -0,0 +1,6 @@ +# `consume` et `export` sont des bind mounts de DONNÉES, pas de la +# configuration : les documents déposés pour ingestion et les exports générés +# tombent dans le dépôt puisque les montages sont relatifs à ce dossier. +# Sans cette règle, un `git add -A` sur chioggia les committerait. +/consume/ +/export/ diff --git a/stacks/paperless.opytex.org/README.md b/stacks/paperless.opytex.org/README.md new file mode 100644 index 0000000..9066f9c --- /dev/null +++ b/stacks/paperless.opytex.org/README.md @@ -0,0 +1,121 @@ +# Paperless-ngx — paperless.opytex.org + +Gestion documentaire (OCR + indexation). Cinq conteneurs : `webserver` +(l'application), `db` (PostgreSQL), `broker` (Redis, file de tâches), +`gotenberg` et `tika` (conversion des documents bureautiques et des `.eml`). + +Authentification : SSO Authentik, plus un compte admin local (`waha-local`) +qui sert de porte de secours si le SSO tombe. + +## Déploiement + +Depuis ce dossier, sur chioggia : + +```bash +sops exec-env secrets.env.yaml 'docker compose up -d' +``` + +Le nom du dossier (`paperless.opytex.org`) donne le nom de projet Compose +`paperlessopytexorg`, dont dérivent le volume `paperlessopytexorg_redisdata` +et le nom du dump de base. **Renommer ce dossier ferait repartir la stack sur +des volumes vides.** + +Après une rotation de secret, `docker compose up -d` répond « up to date » et +garde l'ancienne valeur : + +```bash +sops exec-env secrets.env.yaml 'docker compose up -d --force-recreate' +``` + +## Secrets + +Quatre valeurs dans `secrets.env.yaml`, injectées par `sops exec-env` : +`PAPERLESS_SECRET_KEY`, `PAPERLESS_DBPASS`, `PAPERLESS_ADMIN_PASSWORD` et +`SSO_CLIENT_SECRET`. La clé age de `waha` permet de les éditer directement sur +chioggia, sans jamais écrire de fichier en clair : + +```bash +sops edit secrets.env.yaml +``` + +`PAPERLESS_DBPASS` ne se change pas seul : PostgreSQL ignore +`POSTGRES_PASSWORD` sur un `PGDATA` déjà initialisé, une nouvelle valeur ne +ferait donc que casser la connexion de l'application, en boucle de +redémarrage. Il faut la changer aussi dans la base (`ALTER USER`). + +Le `client_id` SSO n'est pas un secret : il est en clair dans +`docker-compose.yml`, avec le reste du bloc `PAPERLESS_SOCIALACCOUNT_PROVIDERS`. + +## Le même identifiant sous deux noms + +La base est nommée par deux jeux de variables, dans `env_file` : + +- `PAPERLESS_DBNAME` / `PAPERLESS_DBUSER` : lus par `webserver` pour se + connecter. +- `POSTGRES_DB` / `POSTGRES_USER` : lus par `db`, et par le script de + sauvegarde qui fait `docker exec printenv POSTGRES_DB / POSTGRES_USER / + POSTGRES_PASSWORD` sur le conteneur `db`. Le script retombe silencieusement + sur `postgres` si la variable manque : le dump réussirait alors sur la + mauvaise base. + +Les deux jeux doivent porter **la même valeur**. `POSTGRES_PASSWORD` fait +exception : il n'est pas dans `env_file`, il vient de SOPS via +`PAPERLESS_DBPASS`, dans le bloc `environment:` du service `db`. + +## `consume` et `export` + +Deux bind mounts **relatifs à ce dossier**, donc situés dans le dépôt : + +| Montage | Rôle | +|---|---| +| `./consume` | tout fichier déposé ici est ingéré puis supprimé | +| `./export` | destination de `document_exporter` | + +Ce sont des données, pas de la configuration : le `.gitignore` local les +exclut du dépôt. Ne pas retirer ces règles, ce sont des documents personnels. + +Ces dossiers doivent appartenir à l'UID/GID déclaré dans `env_file` +(`USERMAP_UID` / `USERMAP_GID`, 1000) : Docker les crée en `root` s'ils +n'existent pas, et Paperless ne peut alors plus supprimer les fichiers qu'il a +consommés. + +```bash +mkdir -p consume export && chown 1000:1000 consume export +``` + +## Volumes + +| Volume | Contenu | Sauvegarde | +|---|---|---| +| `paperless-data` | index de recherche, classifieur, base des tâches | label `backup.enable=true` + `MANUAL_VOLUMES` | +| `paperless-media` | **les documents originaux et leurs archives PDF** | label `backup.enable=true` + `MANUAL_VOLUMES` | +| `paperless-pg-data` | base PostgreSQL | dump SQL (`backup.type=postgres` sur le conteneur `db`) | +| `paperlessopytexorg_redisdata` | file de tâches Redis | non sauvegardé, volontairement — reconstruit | + +Les trois premiers portent un `name:` explicite, sans préfixe de projet. + +Les labels de volume ne s'appliquent qu'à la création : en ajouter un — ou en +retirer un — ne changerait rien au volume réel, mais ferait diverger sa +déclaration, et Compose proposerait alors de le **recréer**. Répondre « y » à +cette question détruirait les documents. + +`paperless-data` et `paperless-media` figurent en plus dans `MANUAL_VOLUMES` +(`backup.conf`), ce qui les fait sauvegarder deux fois — sans conséquence, +restic déduplique. `paperless-pg-data` n'a délibérément aucun label : +sauvegarder les fichiers d'un PostgreSQL en cours d'écriture ne donne pas une +base restaurable, c'est le dump SQL qui le couvre. + +## Version des images + +Paperless-ngx applique des migrations de schéma **irréversibles** à chaque +montée de version : une mise à jour se fait seule, après sauvegarde vérifiée, +et ne se revient pas en repassant l'image à la version précédente. + +`apache/tika:latest` est le seul tag flottant du dépôt. Il vient de la +documentation amont, mais il rend la stack non reproductible et invisible pour +Renovate — à figer sur la version réellement en service. + +Références : + +- https://docs.paperless-ngx.com/configuration/ +- https://docs.goauthentik.io/integrations/services/paperless-ngx/ diff --git a/stacks/paperless.opytex.org/docker-compose.yml b/stacks/paperless.opytex.org/docker-compose.yml new file mode 100644 index 0000000..24eabac --- /dev/null +++ b/stacks/paperless.opytex.org/docker-compose.yml @@ -0,0 +1,117 @@ +services: + broker: + image: docker.io/library/redis:7 + restart: unless-stopped + volumes: + - redisdata:/data + networks: + - default + + db: + image: docker.io/library/postgres:16 + restart: unless-stopped + env_file: + - env_file + environment: + POSTGRES_PASSWORD: ${PAPERLESS_DBPASS:?} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - default + labels: + - backup.enable=true + - backup.type=postgres + + webserver: + image: ghcr.io/paperless-ngx/paperless-ngx:2.13 + restart: unless-stopped + depends_on: + - db + - broker + - gotenberg + - tika + env_file: + - env_file + environment: + PAPERLESS_DBPASS: ${PAPERLESS_DBPASS:?} + PAPERLESS_SECRET_KEY: ${PAPERLESS_SECRET_KEY:?} + PAPERLESS_ADMIN_PASSWORD: ${PAPERLESS_ADMIN_PASSWORD:?} + # Le client_id n'est pas un secret ; seul `secret` vient de SOPS. + # Ce bloc reste dans `environment:` et non dans env_file : lui seul + # subit l'interpolation ${...} de Compose. + PAPERLESS_SOCIALACCOUNT_PROVIDERS: > + { + "openid_connect": { + "APPS": [ + { + "provider_id": "authentik", + "name": "Authentik", + "client_id": "wu1TWyQ5sIADMhyy04rcOWo95Ciu8g0yKizKF4QQ", + "secret": "${SSO_CLIENT_SECRET:?}", + "settings": { + "server_url": "https://sso.opytex.org/application/o/paperless/.well-known/openid-configuration" + } + } + ], + "OAUTH_PKCE_ENABLED": "True" + } + } + volumes: + - data:/usr/src/paperless/data + - media:/usr/src/paperless/media + # Bind mounts relatifs au dossier de la stack : leur chemin sur l'hôte + # change avec le déplacement du dépôt. Voir README.md. + - ./export:/usr/src/paperless/export + - ./consume:/usr/src/paperless/consume + networks: + - default + - traefik-proxy + labels: + - traefik.enable=true + - traefik.docker.network=traefik-proxy + - traefik.http.routers.paperlessngx.entrypoints=web-secure + - traefik.http.routers.paperlessngx.rule=Host(`paperless.opytex.org`) + - traefik.http.routers.paperlessngx.tls.certresolver=letsencrypt + - traefik.http.routers.paperlessngx.middlewares=crowdsec@file + - traefik.http.services.paperlessngx.loadbalancer.server.port=8000 + + gotenberg: + image: docker.io/gotenberg/gotenberg:8.7 + restart: unless-stopped + # La route chromium sert à convertir les .eml : ni javascript ni contenu + # distant (pixels de tracking). + command: + - "gotenberg" + - "--chromium-disable-javascript=true" + - "--chromium-allow-list=file:///tmp/.*" + networks: + - default + + tika: + image: docker.io/apache/tika:latest + restart: unless-stopped + networks: + - default + +volumes: + # Déclarations reprises telles quelles de l'ancien compose : les volumes + # existent déjà sur chioggia, et un label de volume ne s'applique qu'à la + # création. Ajouter ou retirer un label ferait diverger la déclaration du + # volume réel, et Compose proposerait de le RECRÉER. + # + # `name:` fige les noms hérités, sans le préfixe de projet. + data: + name: paperless-data + labels: + backup.enable: true + media: + name: paperless-media + labels: + backup.enable: true + pgdata: + name: paperless-pg-data + redisdata: + +networks: + traefik-proxy: + external: true diff --git a/stacks/paperless.opytex.org/env_file b/stacks/paperless.opytex.org/env_file new file mode 100644 index 0000000..fbf9f77 --- /dev/null +++ b/stacks/paperless.opytex.org/env_file @@ -0,0 +1,54 @@ +# ============================================================================= +# PAPERLESS-NGX — configuration NON sensible +# ============================================================================= +# PAPERLESS_SECRET_KEY, PAPERLESS_DBPASS, PAPERLESS_ADMIN_PASSWORD et +# SSO_CLIENT_SECRET sont dans secrets.env.yaml, injectés par `sops exec-env`. +# +# Ce fichier ne contient QUE ce que les conteneurs consomment. Le domaine, la +# version d'image et le bloc SSO restent en dur dans docker-compose.yml : +# `env_file:` n'alimente pas l'interpolation ${...}, et une version en dur est +# détectée par Renovate. +# ============================================================================= + +# --- UID/GID du process paperless dans le conteneur --- +# Doit correspondre au propriétaire du dossier `consume` sur l'hôte. +USERMAP_UID=1000 +USERMAP_GID=1000 + +# --- Domaine --- +PAPERLESS_URL=https://paperless.opytex.org + +# --- Divers --- +PAPERLESS_TIME_ZONE=Europe/Paris + +# --- OCR --- +# LANGUAGES : paquets tesseract installés au démarrage. +# LANGUAGE : langue utilisée pour l'OCR. +PAPERLESS_OCR_LANGUAGES=eng fra +PAPERLESS_OCR_LANGUAGE=fra+eng + +# --- Services internes --- +PAPERLESS_REDIS=redis://broker:6379 +PAPERLESS_DBHOST=db +PAPERLESS_TIKA_ENABLED=1 +PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000 +PAPERLESS_TIKA_ENDPOINT=http://tika:9998 + +# --- Base de données --- +# POSTGRES_* est lu par le conteneur `db` (et par le script de sauvegarde, qui +# fait `docker exec printenv POSTGRES_DB/USER/PASSWORD` pour le dump). +# PAPERLESS_DB* est lu par le conteneur `webserver`. Les deux doivent +# concorder. +POSTGRES_DB=paperless +POSTGRES_USER=paperless +PAPERLESS_DBNAME=paperless +PAPERLESS_DBUSER=paperless + +# --- Compte administrateur local --- +# Porte de secours si le SSO tombe. Le mot de passe est dans secrets.env.yaml. +PAPERLESS_ADMIN_USER=waha-local + +# --- SSO OpenID Connect (Authentik) --- +# Le provider lui-même est déclaré dans PAPERLESS_SOCIALACCOUNT_PROVIDERS, +# côté docker-compose.yml. +PAPERLESS_APPS=allauth.socialaccount.providers.openid_connect diff --git a/stacks/paperless.opytex.org/secrets.env.yaml b/stacks/paperless.opytex.org/secrets.env.yaml new file mode 100644 index 0000000..2a2974b --- /dev/null +++ b/stacks/paperless.opytex.org/secrets.env.yaml @@ -0,0 +1,28 @@ +PAPERLESS_SECRET_KEY: ENC[AES256_GCM,data:tE2X6Pt/bSMpbgVo6IGQMjZ0p4AsQEXoQft8l7PNgBw1EWcjz0mSJ5+tjGgCcSnzRAo=,iv:LvVv5PCgy6ZnitpG2y5JG95V10/Ky0hgN9JyND5w+oM=,tag:bGXbX3+IP/vcj5SluPryqw==,type:str] +PAPERLESS_ADMIN_PASSWORD: ENC[AES256_GCM,data:jhmtzvaumj8kepDLGaMFUDVLZug=,iv:wdhUgllGyPadWGhOqt4nTprBO+uJM1ksNVgYRAfmt8Q=,tag:2n/aQKW8eJgNyozTbRkd6A==,type:str] +PAPERLESS_DBPASS: ENC[AES256_GCM,data:9lvz6bH9toUY,iv:QV6fb3oVNiGiMTrmegzW11eP1GVlEYIfxn/Vexcmqu4=,tag:v+ElzCZuTuc6LPRclPvBww==,type:str] +SSO_CLIENT_SECRET: ENC[AES256_GCM,data:YQEDOt9SlbJJ+ex5s4Ix4G/xtCZIT96UlE4u9FKngYfE+dlpKjoqvMq4iCGNxF85ybEpnF69UmmTz1apBIPMkCJGphwNESi6ZDIaHHeUli5ZfOaikU6EJISmM5C83q/z3P4uTKGiYzHOrwqoR7q/I7AI010nYaaLLIFtoaS6CjY=,iv:TOQjuZGR5QLmGWtkbBybatTDodwE2r7qXUnAKK4j+ys=,tag:RZEayii5Za5UEfoV+uFXpQ==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJQ3N5YS9oV2drUHRQTEhj + ZXcyeXIyRkFETlBiZWY2RlZtSVZTL3ZxS1djCmFEanRPUWwzSjAyNU55bGJBanZx + SnFYUmZYV2lMbnhEeE1Ob0JaWlE1ZDAKLS0tIFZOSGVtYUphR0cxdnd4VnpMd3pE + eUxTb20yZnIxcWdsU3ZJdi9hZUdYd1UKVkUwEN2sjeuz3uDy/qXqfZR0XboYiNlY + mTxiZ+Kn9lpCUgWJql4DJbYFffUMOtJIPjbfOudZRNZe9N/ez0nZHA== + -----END AGE ENCRYPTED FILE----- + recipient: age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPTDRkK25xOTFqQ2pjcHFk + WnB5R0VPY1dKdjU3Q0xJaTI5eFVFR0VzdGtVCmMxRHdUMTdlWE1aNVlZVmdxNkNu + Rit0YjBSbTVSNG9lN29CMThldzU0MEUKLS0tIFpiYzFRZUZZQlRwV1pmOWVYUlRY + cWYyUnNQU1dqMndoK09DQmNUR1d6Y2MKLGPkFeMO4XBhnNY63D2C5dO+X6nlXhQ6 + AP2xgImvNy3DSxDfjb8o6dfhwZpMHLLyK6bUqb9afLnTt67E9WtmvQ== + -----END AGE ENCRYPTED FILE----- + recipient: age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj + lastmodified: "2026-08-17T14:20:44Z" + mac: ENC[AES256_GCM,data:s04pAb0JwEdjCgY6vA6ZtBWulrESprXtAuyUzzfzPnVWXa/fv7e84ur2jwI+iKNh/T1SEYeCl5MQ8bV6qZiGIWWDXo4YVlHGxUl+peLOek9Ej2StYkNh7ntm4mOaYug60z7/nIAUdjIc81xo9HT1TLnBnrGZFH8uZ2/1aizug3U=,iv:DhvmLJLbRZC2MoLTEzwQaRvsv5gfixfnOpN9bTQlmOM=,tag:wEODwURmDKJNUhmJkh4pyQ==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.3