Compare commits

..

24 Commits

Author SHA1 Message Date
aab8280ff8 feat(forgejo): change group for runner 2026-08-20 14:39:27 +00:00
dbf771515f fix(forgejo): change runner config 2026-08-20 16:32:55 +02:00
dea4604828 feat(forge): add forgejo 2026-08-20 15:11:59 +02:00
0e301e76bd feat(paperless): update to version 3.0.5 2026-08-20 14:54:55 +02:00
3f6a3475c8 feat(paperless): move consume and export to own volume 2026-08-20 14:45:21 +02:00
3844100012 feat(matrix): clean livekit, nginx 2026-08-20 14:28:17 +02:00
4ad4bb59c6 feat(stacks): upgrade version for matrix stack 2026-08-19 18:48:05 +02:00
948358971d feat(stacks): fix version for matrix 2026-08-19 18:39:09 +02:00
1b221dfec8 feat(stacks): readme for matrix 2026-08-19 18:36:33 +02:00
885c3b62ea fix: rotate POSTGRES_PASSWORD for matrix 2026-08-19 16:34:02 +00:00
c7147957b1 fix: rotate TURN_STATIC_AUTH_SECRET 2026-08-19 16:28:54 +00:00
e6a8ad6547 fix: turn TURN_CLI_PASSWORD, LIVEKIT_KEY, LIVEKIT_SECRET 2026-08-19 16:25:18 +00:00
1c05fd3578 fix(stacks): coturn annonce la bonne IP et publie 3478 en UDP
`external-ip` pointait sur 91.121.90.228, l'adresse d'admin.opytex.org, alors
que la stack tourne sur chioggia (188.165.240.182) : coturn annonçait aux
clients une machine qui n'écoute rien sur ces ports.

3478 n'était par ailleurs publié qu'en TCP, alors qu'un client TURN tente
l'UDP en premier. Les deux défauts se cumulaient, le relais était inopérant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:48:23 +02:00
0584856ea3 feat(stasks): migrate matrix 2026-08-19 17:10:23 +02:00
b002113473 feat(stacks): update paperless to 1.20.15 2026-08-18 06:21:55 +02:00
b602d42c92 feat(stacks): on paperless update db (16.15), tika (3.3.1.0), gotenberg
(8.34.0) and redis (balkey 9.1.1)
2026-08-18 05:42:21 +02:00
8fdf461ae1 feat(stacks): migrate paperless 2026-08-17 16:32:13 +02:00
306667b6e1 feat(stacks): update PG version to 16.15 for rss 2026-08-17 15:29:51 +02:00
c2cf727bdf feat(stacks): update freshrss to 1.29.1 2026-08-17 15:23:39 +02:00
3c70a467ed fix(stacks): change OIDC_CLIENT_SECRET 2026-08-17 15:14:19 +02:00
b220593def feat(stacks): migrate rss 2026-08-17 14:58:35 +02:00
322f2f89b5 feat(stacks): migrate mail-relay 2026-08-17 13:35:57 +02:00
122881e539 feat(stacks): rotate traefik-crowdsec secret 2026-08-14 06:27:53 +02:00
10fff06464 feat(stacks): migrate traefik 2026-08-14 06:21:01 +02:00
38 changed files with 2000 additions and 1 deletions

4
.gitignore vendored
View File

@@ -64,3 +64,7 @@ terraform.rc
*.agekey
age-key.txt
keys.txt
# Le stockage ACME de Traefik : clé de compte Let's Encrypt et clés privées de
# tous les certificats. Vit sur le serveur uniquement, jamais dans le dépôt.
acme.json

View File

@@ -285,3 +285,19 @@ resource "ovh_domain_zone_record" "maintenant_aaaa" {
ttl = 3600
target = "2001:41d0:2:bbb6:0:0:0:1"
}
resource "ovh_domain_zone_record" "forge_a" {
zone = "opytex.org"
subdomain = "forge"
fieldtype = "A"
ttl = 3600
target = "188.165.240.182"
}
resource "ovh_domain_zone_record" "forge_aaaa" {
zone = "opytex.org"
subdomain = "forge"
fieldtype = "AAAA"
ttl = 3600
target = "2001:41d0:2:bbb6:0:0:0:1"
}

View File

@@ -0,0 +1,189 @@
# Forgejo — forge.opytex.org
Forge git. Quatre conteneurs : `forgejo` (l'application), `postgres`,
`cache` (Valkey) et `runner` (Forgejo Actions).
Le nom de ce dossier donne le nom de projet Compose `forgeopytexorg`, dont
dérivent `forgeopytexorg_forgejo_data` et le nom du dump de base. **Le renommer
ferait repartir la forge sur des volumes vides.**
## Déploiement
Depuis ce dossier, sur chioggia :
```bash
sops exec-env secrets.env.yaml 'docker compose up -d'
```
Les secrets sont déclarés `${VAR:?}` : **toute** commande `docker compose`
échoue sans l'enveloppe `sops exec-env`, y compris `logs`, `ps` et `exec`.
Après une rotation de secret, `up -d` répond « up to date » et garde l'ancienne
valeur — il faut `--force-recreate`.
## Mise en service, dans l'ordre
Les cinq étapes ci-dessous sont à faire une seule fois, **dans cet ordre**.
L'ordre n'est pas indifférent : voir l'étape 3.
### 1. Remplir les secrets
Deux valeurs, toutes deux en **placeholder visible** (`REMPLACER-…`) :
```bash
sops edit secrets.env.yaml
```
| Clé | Origine |
|---|---|
| `POSTGRES_PASSWORD` | à générer (`pwgen -s 32 1`) |
| `SSO_CLIENT_SECRET` | copié depuis le provider Authentik créé à l'étape 2 |
`SSO_CLIENT_SECRET` n'est **pas** lu par le conteneur : il est conservé ici pour
ne pas être perdu, et injecté à la main dans la commande `add-oauth` ci-dessous.
### 2. Créer le provider OIDC dans Authentik
Application + provider OAuth2/OpenID, avec pour URL de redirection :
```
https://forge.opytex.org/user/oauth2/authentik/callback
```
Le segment `authentik` est le nom donné à la source à l'étape 4 : les deux
doivent coïncider. Le reste de l'URL suit `FORGEJO__server__ROOT_URL` — si le
domaine de la forge change, cette redirection doit changer avec lui.
### 3. Créer le compte administrateur local
**Avant toute connexion SSO.** Forgejo donne les droits d'administration au
premier utilisateur créé : si quelqu'un se connecte via Authentik avant cette
étape, c'est son compte personnel qui devient administrateur de l'instance.
```bash
sops exec-env secrets.env.yaml 'docker compose exec -u git forgejo \
forgejo admin user create --admin \
--username waha-local \
--email waha-local@opytex.org \
--random-password'
```
Le mot de passe s'affiche une seule fois : le ranger dans `pass` immédiatement.
Ce compte est **volontairement local**, hors SSO. C'est la même porte de secours
que sur `paperless.opytex.org` : il reste utilisable si Authentik tombe ou si la
configuration OIDC est cassée — y compris pour réparer cette configuration.
Il est aussi volontairement **distinct de tout compte personnel** : les comptes
nominatifs créés par SSO restent des utilisateurs ordinaires. Après la première
connexion SSO, vérifier dans *Administration → Utilisateurs* qu'aucun compte
nominatif ne porte le drapeau administrateur, et le retirer le cas échéant.
### 4. Déclarer la source d'authentification
Une seule fois, forge démarrée :
```bash
sops exec-env secrets.env.yaml 'docker compose exec -u git forgejo \
forgejo admin auth add-oauth \
--name authentik \
--provider openidConnect \
--key <CLIENT_ID_AUTHENTIK> \
--secret "$SSO_CLIENT_SECRET" \
--auto-discover-url https://sso.opytex.org/application/o/forgejo/.well-known/openid-configuration \
--scopes "openid email profile"'
```
Le `client_id` n'est pas un secret : il peut rester en clair dans cette commande.
**Vérifier immédiatement qu'un compte se crée bien à la première connexion.**
Le trio `DISABLE_REGISTRATION` / `ALLOW_ONLY_EXTERNAL_REGISTRATION` /
`ENABLE_AUTO_REGISTRATION` d'`env_file` est contre-intuitif : mettre
`DISABLE_REGISTRATION=true`, ce qui semble être le réglage prudent, bloque
aussi l'auto-création via Authentik et **verrouille la forge pour tout le
monde**. C'est là que le compte de l'étape 3 sert.
L'auto-création vaut pour **toute personne autorisée par Authentik** à atteindre
l'application. Restreindre cet accès est un réglage d'Authentik, pas de Forgejo :
lier un groupe à l'application, sans quoi n'importe quel compte de l'annuaire se
verra ouvrir un compte sur la forge à sa première visite.
### 5. Raccorder le runner
Il n'y a **pas d'étape d'enregistrement** : la sous-commande `register` du
runner est dépréciée. C'est la forge qui crée le runner et délivre un couple
**UUID + jeton**, que le démon présente ensuite comme un identifiant et un mot
de passe.
Dans *Administration → Actions → Runners*, créer un runner. La page affiche
l'UUID et le jeton — **le jeton n'est montré qu'une fois.**
**a.** Reporter l'UUID dans `runner/config.yaml`, à la place de
`REMPLACER-UUID-DU-RUNNER`. Ce n'est pas un secret, il reste en clair dans le
dépôt.
**b.** Déposer le jeton dans le volume. Il ne doit toucher ni le dépôt, ni le
disque de l'hôte, ni l'historique du shell — d'où la lecture sur l'entrée
standard :
```bash
sops exec-env secrets.env.yaml 'docker compose run --rm --entrypoint sh runner \
-c "umask 077; cat > /data/runner-token"'
# coller le jeton, puis Ctrl-D
```
**c.** Démarrer : `sops exec-env secrets.env.yaml 'docker compose up -d runner'`.
Le runner doit apparaître « en ligne » dans l'administration.
Le jeton vit dans `forgejo_runner_data`, donc `docker compose up -d
--force-recreate` et les redémarrages le préservent. Seul un `down -v` — qui
détruirait aussi tous les dépôts — le ferait disparaître. Dans ce cas :
supprimer le runner dans l'administration, en créer un neuf, et refaire **a**
et **b** ; l'UUID change à chaque fois.
## Dépendance MinIO
`runner/config.yaml` déclare le label `minio-mc`. Deux choses en dépendent, à
reprendre le jour du passage de `storage.opytex.org` (MinIO) à Garage :
1. **Le label** `minio-mc:docker://minio/mc:latest` — image `mc` fournie aux
jobs qui le demandent en `runs-on`.
2. **L'action `lafrite/minio-deploy-action`**, appelée en URL absolue dans les
workflows, qui consomme les secrets Actions `MINIO_ENDPOINT`,
`MINIO_ACCESS_KEY` et `MINIO_SECRET_KEY`.
Le tag `latest` combiné à `force_pull: true` fait suivre `mc` en amont sans
préavis. Rien n'est changé ici, c'est un constat.
## Volumes et sauvegarde
| Volume | Contenu | Sauvegarde |
|---|---|---|
| `forgeopytexorg_forgejo_data` | **tous les dépôts**, config, avatars, paquets | label `backup.enable=true` |
| `forgeopytexorg_forgejo_db` | base PostgreSQL | dump SQL (`backup.type=postgres` sur le conteneur) |
| `forgeopytexorg_forgejo_runner_data` | le jeton du runner (`runner-token`) | non sauvegardé — se recrée depuis l'administration |
Les labels de volume ne s'appliquent qu'à la création : ceux-ci sont donc
effectifs. Ne pas en retirer ni en ajouter par la suite — Compose détecte la
divergence entre le volume et sa déclaration, et propose de le **recréer**.
`forgejo_db` n'a délibérément aucun label : sauvegarder les fichiers d'un
PostgreSQL en cours d'écriture ne donne pas une base restaurable.
Ajouter `forgeopytexorg_forgejo_data` à `MANUAL_VOLUMES` (`backup.conf`) reste
utile en ceinture-bretelles, comme pour les autres stacks — restic déduplique.
## Version
Forgejo **v15.0.3 (LTS)**, supportée jusqu'en juillet 2027. La branche non-LTS
(v16) a un horizon de support de quelques mois seulement : inadapté pour la
forge qui héberge le dépôt `infra`.
Ne pas monter de version majeure sans sauvegarde vérifiée — les migrations de
schéma ne se reviennent pas.
Références :
- https://forgejo.org/docs/latest/admin/
- https://forgejo.org/releases/

View File

@@ -0,0 +1,99 @@
services:
forgejo:
image: codeberg.org/forgejo/forgejo:15.0.3
restart: unless-stopped
depends_on:
- postgres
- cache
env_file:
- env_file
environment:
FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD:?}
volumes:
- forgejo_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
# Port hôte du SSH git. Doit rester cohérent avec
# FORGEJO__server__SSH_PORT dans env_file, qui est ce que la forge
# annonce dans ses URL de clone.
- "2223:22"
networks:
- default
- traefik-proxy
- smtp
labels:
- traefik.enable=true
- traefik.docker.network=traefik-proxy
- traefik.http.routers.forgejo.entrypoints=web-secure
- traefik.http.routers.forgejo.rule=Host(`forge.opytex.org`)
- traefik.http.routers.forgejo.tls.certresolver=letsencrypt
- traefik.http.routers.forgejo.middlewares=crowdsec@file
- traefik.http.services.forgejo.loadbalancer.server.port=3000
postgres:
image: postgres:18
restart: unless-stopped
env_file:
- env_file
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
volumes:
- forgejo_db:/var/lib/postgresql
networks:
- default
labels:
- traefik.enable=false
- backup.enable=true
- backup.type=postgres
cache:
# Pas de `container_name` : le nom serait global au démon. Ce Valkey est
# réservé à cette forge — deux forges partageant un même Redis se volent
# leurs événements, avec des symptômes qui n'y font jamais penser.
image: valkey/valkey:9.0.3-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 15s
timeout: 3s
retries: 30
logging:
driver: "json-file"
options:
max-size: "1m"
networks:
- default
runner:
image: data.forgejo.org/forgejo/runner:13
restart: unless-stopped
depends_on:
- forgejo
# URL, UUID et emplacement du jeton sont dans runner/config.yaml.
command: ["forgejo-runner", "daemon", "--config", "/config.yaml"]
volumes:
- ./runner/config.yaml:/config.yaml:ro
- forgejo_runner_data:/data
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "1002"
networks:
- default
volumes:
# Les labels de volume ne s'appliquent qu'à la création : ceux-ci sont
# effectifs. Ne pas en ajouter ni en retirer ensuite, Compose proposerait de
# recréer le volume.
forgejo_data:
labels:
backup.enable: true
forgejo_db:
forgejo_runner_data:
networks:
traefik-proxy:
external: true
smtp:
external: true

View File

@@ -0,0 +1,80 @@
# =============================================================================
# FORGEJO — configuration NON sensible
# =============================================================================
# Seul POSTGRES_PASSWORD est chiffré (secrets.env.yaml, injecté par
# `sops exec-env`). Le secret client Authentik n'est pas ici : il n'est pas lu
# par le conteneur, il se saisit une fois via `forgejo admin auth add-oauth`.
#
# Ce fichier ne contient QUE ce que les conteneurs consomment. La version
# d'image et les règles Traefik sont en dur dans docker-compose.yml :
# `env_file:` n'alimente pas l'interpolation ${...}, et une version en dur est
# détectée par Renovate.
# =============================================================================
# --- Base de données ---
# POSTGRES_* est lu par le conteneur postgres ET par le script de sauvegarde,
# qui fait `docker exec printenv POSTGRES_DB / POSTGRES_USER`. FORGEJO__database__*
# est lu par l'application. Les deux jeux doivent porter la MÊME valeur.
POSTGRES_USER=forgejo
POSTGRES_DB=forgejo
FORGEJO__database__DB_TYPE=postgres
FORGEJO__database__HOST=postgres:5432
FORGEJO__database__NAME=forgejo
FORGEJO__database__USER=forgejo
# --- Conteneur ---
USER_UID=1000
USER_GID=1000
TZ=Europe/Paris
# --- URL publique ---
# ROOT_URL est recopié dans les URL de clone affichées, dans les webhooks et
# dans le redirect OIDC : le changer suppose de reprendre le provider Authentik
# et de recréer les webhooks.
FORGEJO__server__DOMAIN=forge.opytex.org
FORGEJO__server__ROOT_URL=https://forge.opytex.org/
FORGEJO__server__SSH_DOMAIN=forge.opytex.org
FORGEJO__server__SSH_PORT=2223
FORGEJO__server__SSH_LISTEN_PORT=22
# --- Cache ---
# `cache` est le service Valkey de CETTE stack, et doit lui rester réservé :
# deux forges partageant un même Redis se volent leurs événements, avec des
# symptômes trompeurs (`AesDecrypt invalid decrypted base64 string`).
FORGEJO__cache__ENABLED=true
FORGEJO__cache__ADAPTER=redis
FORGEJO__cache__HOST=redis://cache:6379/0?pool_size=100&idle_timeout=180s
FORGEJO__cache__ITEM_TTL=24h
# --- Inscriptions & SSO (Authentik) ---
# Combinaison volontaire et contre-intuitive : DISABLE_REGISTRATION=true
# bloquerait AUSSI l'auto-création via OIDC, et plus personne ne pourrait
# entrer. C'est ALLOW_ONLY_EXTERNAL_REGISTRATION qui interdit l'inscription
# locale tout en laissant passer Authentik.
FORGEJO__service__DISABLE_REGISTRATION=false
FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION=true
FORGEJO__service__SHOW_REGISTRATION_BUTTON=false
FORGEJO__openid__ENABLE_OPENID_SIGNIN=false
FORGEJO__oauth2_client__ENABLE_AUTO_REGISTRATION=true
FORGEJO__oauth2_client__ACCOUNT_LINKING=auto
FORGEJO__oauth2_client__USERNAME=nickname
FORGEJO__oauth2_client__UPDATE_AVATAR=true
# --- Actions & registre de paquets ---
# Le registre est une dépendance vivante de l'infra : stacks/plesna.opytex.org
# tire une image depuis cette forge. Explicite pour éviter toute désactivation
# par inadvertance.
FORGEJO__actions__ENABLED=true
FORGEJO__packages__ENABLED=true
# --- Courriel (relais interne postfix, réseau `smtp`) ---
FORGEJO__mailer__ENABLED=true
FORGEJO__mailer__PROTOCOL=smtp
FORGEJO__mailer__SMTP_ADDR=postfix
FORGEJO__mailer__SMTP_PORT=25
FORGEJO__mailer__FROM=noreply@opytex.org
# --- Divers ---
FORGEJO__repository__DEFAULT_BRANCH=main
FORGEJO__security__INSTALL_LOCK=true

View File

@@ -0,0 +1,75 @@
# Configuration du Forgejo Runner (v13).
#
# Ce fichier est versionné en clair : il ne doit contenir AUCUN secret. Le
# jeton du runner est lu dans le volume via `token_url` — voir README.md.
#
# Les valeurs non mentionnées ici gardent les défauts de l'image. Pour les
# revoir : `docker compose run --rm --entrypoint forgejo-runner runner
# generate-config`.
log:
# Sortie du processus runner.
level: info
# Ce qui remonte dans le journal du job, visible dans l'interface web.
job_level: info
runner:
# 4 jobs simultanés, comme l'ancien runner. Le défaut de l'image est 1.
capacity: 4
timeout: 3h
fetch_interval: 2s
insecure: false
# Les labels associent un `runs-on` de workflow à une image.
# Ces images viennent du registre de Gitea : elles sont conservées telles
# quelles parce que les workflows existants supposent leur outillage.
# En changer romprait des builds sans prévenir.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
- "ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04"
# Consommé par les workflows qui déposent sur storage.opytex.org (MinIO).
# À revoir le jour du passage à Garage — voir README.md, section
# « Dépendance MinIO ».
- "minio-mc:docker://minio/mc:latest"
cache:
# Fournit ACTIONS_CACHE_URL aux workflows qui utilisent l'action `cache`.
enabled: true
# Adresse annoncée aux conteneurs de job pour joindre le proxy de cache.
# Vide = détection automatique, correcte tant que le démon Docker tourne sur
# la même machine que le runner. C'est ici qu'il faut forcer une IP si
# `actions/cache` reste bloqué.
host: ""
container:
network: ""
privileged: false
# Aucun volume montable par les jobs.
valid_volumes: []
# ATTENTION AU SENS DE CETTE VALEUR — il a changé en v13.
# "-" ou "" : aucun socket Docker monté dans le conteneur de job
# "automount" : socket de l'hôte détecté et monté (défaut d'avant la v13)
# `automount` est nécessaire à docker/build-push-action (notytex). En
# contrepartie, tout job Actions dispose du démon Docker de l'hôte, donc de
# droits équivalents à root sur chioggia.
docker_host: "automount"
# Retire les images à chaque exécution plutôt que de réutiliser une copie
# locale. Indispensable ici : le label `minio-mc` pointe sur un tag flottant
# (`latest`). Le défaut de l'image est `false`.
force_pull: true
force_rebuild: false
server:
connections:
forgejo:
url: https://forge.opytex.org/
# L'UUID identifie le runner : ce n'est pas un secret, il tient le rôle
# d'un nom d'utilisateur.
uuid: 0c25170d-cac2-4342-810e-2880e8833b95
# Le jeton, lui, en est un. Il vit dans le volume forgejo_runner_data et
# n'apparaît donc jamais dans le dépôt. `token` et `token_url` sont
# mutuellement exclusifs : ne pas ajouter `token:` ici.
token_url: file:///data/runner-token

View File

@@ -0,0 +1,26 @@
POSTGRES_PASSWORD: ENC[AES256_GCM,data:BucVeYibiQFQWzp7ncTNm/wfCT4x0eTCMCj62CA59xsp1p4XfbVq4phdpkP+cwp+lB/M9m04UiN2l5ievF5Uzg==,iv:I9vk3+/JvF4k3mbpvPqVeC6k2oF2ym9zPvBxN9p1kOA=,tag:m46/+gcDLuALftSJJmQNtQ==,type:str]
SSO_CLIENT_SECRET: ENC[AES256_GCM,data:Df+Nppy6tqv5ersu81SKleDQUWq33sIVGsfOn+oiq1jvmmLD7+TFZyTElsPLT/9wOoiHdjGOSqrEKRWW/ZnMrtlxoUmpRXbMntVcLhQIwRZNYa7YptwrLzmkdNqtYsIKYag4MYb2fnu7yqRrOpEwIInrnKhSPwgAlFbYG7yKqfo=,iv:jqDiBGOetJOwlfnhfljjN5Bbga68oaHqcK7O4Pw7fgI=,tag:H0iMVYGqx2IzlWFCM8ETRw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvMGFWTVZwbVBDZ3NIdGlr
MVlGaW1QTGJsUnNHbEpVM2NITXFZTW5aaFZVCklkc2t6S1JlTmhteGNQcjdGOCtT
TS8yazMyRjdmdlpGaWVjUVpmLzlYTmsKLS0tICsvM3lkWTVqa1VyTTJINWtPRmp2
b2VCY09ITEhCMlhXSzc5VkhtWXZHaWMK15PQvEbBK7fRNfLKMbNhXpcfFWKA/CQE
LhE+kymk1XFzB/Ce/+YgP1/ydyZOJK+uN/6+hwBMO82G//Aiep3abQ==
-----END AGE ENCRYPTED FILE-----
recipient: age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpRUcwbm5rT00zV3J0TG5K
Zm1nNVRSUmtMZU03QTB3ZGJtazQ4SnlnVEVnCjAwNlNIbmtpTVZVNlE5dlhYdHBk
VnAveC9KSGx2WUJhb1pGbWJjMTNXeVEKLS0tIHUvcUlmczhrODRwVXhSUzhFN09D
ZFNiTlNwYkI1b1c5aGNtZitreGlzeXMKBrYxAUOhDW5o1oQUahpdyb6JkPZ5o/MT
w7v9A4Qwe8puT1jgIByZRxAEQfMChmGbrdy1PHoeq7DjKYayDRu8fA==
-----END AGE ENCRYPTED FILE-----
recipient: age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj
lastmodified: "2026-08-20T13:09:10Z"
mac: ENC[AES256_GCM,data:VDaU4oyWyIF6SeL8soPV46Aqc6aPaU3q9dZtSQDNV8hHqb7H8SLqS15Q3WacPXdO8Ee+jSydXcTf5wMENsOd2XqNoz+ClKHGZpbyglHFKOA8pNBpGnzBZ1MYgJgEB2BHal/UC17Z5J3uxhGKHD5HDyYTkQLwXkMnvoRJB74k4io=,iv:6hdeZcMpw3Pd8ZPT5eoZPOe3+QLHTJJWUMZRELuQ9iI=,tag:jhEc0vKGd7MQYq9dUNyjiA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3

View File

@@ -0,0 +1,96 @@
# mail-relay — relais SMTP interne
Postfix en mode *satellite* : il n'accepte du courrier que depuis le réseau Docker
`smtp` et le réexpédie au relais du fournisseur, authentifié avec un compte d'envoi.
Pas de FQDN, pas de routeur Traefik, pas de port publié, pas de volume : le dossier
porte donc le nom du service et non un domaine, comme `stacks/traefik/`.
## Contrat avec les autres stacks — à ne pas casser
| Élément | Valeur | Pourquoi c'est figé |
|---|---|---|
| Réseau Docker | `smtp` | déclaré `external: true` par tous les consommateurs |
| Nom d'hôte | `postfix` | valeur de `SMTP_HOST` chez les consommateurs |
| Port | `25` | sans TLS ni authentification, réseau interne uniquement |
Le nom d'hôte `postfix` vient du **nom de service** Compose, pas du nom de projet :
renommer le dossier ne le change pas. Renommer le *service*, en revanche, casserait
tous les consommateurs en silence — les mails cesseraient de partir sans qu'aucun
conteneur ne tombe.
Consommateurs : `vault.opytex.org`, `authentik`, `matrix_poneyworld`.
## Le réseau `smtp` est externe
Il n'est pas créé par cette stack, pour qu'un `docker compose down` ici ne tente pas
de supprimer un réseau auquel d'autres stacks sont attachées. À créer une seule fois,
s'il n'existe pas déjà :
```bash
docker network ls --filter name=^smtp$ --format '{{.Name}}'
docker network create smtp # seulement si la commande ci-dessus ne renvoie rien
```
## Déploiement
```bash
cd ~/infra/stacks/mail-relay
sops exec-env secrets.env.yaml 'docker compose up -d'
```
Après une modification d'un secret, `up -d` répond « up to date » et garde l'ancienne
valeur. Il faut forcer :
```bash
sops exec-env secrets.env.yaml 'docker compose up -d --force-recreate'
```
## Configuration
`env_file` porte le relais amont et l'identité annoncée (`SMTP_SERVER`, `SMTP_PORT`,
`SERVER_HOSTNAME`, `OVERWRITE_FROM`) ; `secrets.env.yaml` porte les identifiants du
compte d'envoi (`SMTP_USERNAME`, `SMTP_PASSWORD`), édités avec `sops edit`.
Les noms sont ceux que l'image lit directement. Pour voir ce que le conteneur a
réellement reçu :
```bash
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' postfix \
| grep -E 'SMTP|SERVER|OVERWRITE'
```
Un `SMTP_USERNAME` ou `SMTP_PASSWORD` vide signale un `sops exec-env` oublié.
## Vérifier que le relais fonctionne
```bash
# le nom d'hôte résout depuis un consommateur
docker exec vaultwarden getent hosts postfix
# envoi de bout en bout
docker exec -i postfix sh -c 'sendmail -t' <<'EOF'
To: destinataire@example.org
Subject: test relais
test
EOF
# file d'attente et journal
docker exec postfix mailq
docker logs --tail 50 postfix
```
Une file d'attente qui ne se vide pas, ou un `status=deferred` dans le journal, pointe
l'authentification auprès du relais amont.
## Points d'attention
- **La file d'attente n'est pas persistée.** `/var/spool/postfix` vit dans la couche
d'écriture du conteneur : un `docker compose up --force-recreate` perd les messages
non encore remis. Acceptable pour des notifications, à revoir si le relais transporte
un jour du courrier qui compte.
- **Aucune sauvegarde.** Pas de volume, rien à restaurer : la stack se reconstruit
entièrement depuis ce dossier plus les secrets.
- Le relais fait confiance à tout ce qui est joint au réseau `smtp`. Ne pas y attacher
un service exposé qui n'a pas besoin d'envoyer du courrier.

View File

@@ -0,0 +1,22 @@
services:
postfix:
image: juanluisbaptiste/postfix:1.9.0
container_name: postfix
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- env_file
environment:
SMTP_USERNAME: ${SMTP_USERNAME:?}
SMTP_PASSWORD: ${SMTP_PASSWORD:?}
networks:
- smtp
networks:
# Créé hors stack (`docker network create smtp`) et déclaré `external` par
# tous les consommateurs. Le garder externe ici aussi évite qu'un
# `docker compose down` sur ce relais tente de supprimer un réseau auquel
# d'autres stacks sont attachées.
smtp:
external: true

View File

@@ -0,0 +1,21 @@
# =============================================================================
# MAIL-RELAY (postfix) — configuration NON sensible
# =============================================================================
# Relais SMTP interne de l'infra. Aucun port publié, aucun routeur Traefik :
# il n'est joignable que sur le réseau Docker `smtp`, sous le nom d'hôte
# `postfix`, port 25, sans authentification ni TLS.
#
# Les noms de variables ci-dessous sont ceux attendus par l'image
# juanluisbaptiste/postfix, consommés directement.
#
# SERVER_HOSTNAME : nom annoncé dans le HELO/EHLO, doit correspondre au SPF.
# OVERWRITE_FROM : réécrit l'expéditeur de tous les messages relayés.
#
# Les identifiants du compte d'envoi (SMTP_USERNAME, SMTP_PASSWORD) sont dans
# secrets.env.yaml et injectés par `sops exec-env`.
# =============================================================================
SMTP_SERVER=smtp.mailfence.com
SMTP_PORT=465
SERVER_HOSTNAME=opytex.org
OVERWRITE_FROM=noreply@opytex.org

View File

@@ -0,0 +1,26 @@
SMTP_USERNAME: ENC[AES256_GCM,data:+7Kp5g9BMDo=,iv:HwSc6EfVkaipNbEKsdSYKKqu2zmKVdfGdwxmF3DeBcU=,tag:HPdOovDuvWh9+5OUep61HQ==,type:str]
SMTP_PASSWORD: ENC[AES256_GCM,data:nkxxgMlpwjoauzF9,iv:aMB9u7i5zVOoZaCCndiV+h43oxT2FiN+uTgo6fU/6/c=,tag:HE6V9Xtly6vcnWIM4fAtIQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBueXNlTFdaRWxHUjBvU0Nv
MFRJQXZobURqdWRJK0dLSmp2WEx5NDMyWFhnCnp5a1ZIN2pSbHQ2aDBVdjV1UUVu
MWNqQjloQXV4QUZoVVZDeUJvMUkvQ00KLS0tIDdSeVA2b1FqVWhHeDdrTDV5bXZ4
T3ZIcVlGdUp1cXBETWxTRTZuVzVUdkEKFiEf3srsmNQND/dAftYWZWhzBZEVJDVN
C7sWjY+VoaUu/kLmkpSkvlU1AkMyiHuIGbFJcZVWkTo7/+nfDqd60Q==
-----END AGE ENCRYPTED FILE-----
recipient: age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXTXM5S0M5cE1mZXJqdWlC
ckx4QVE3YkplUkdUcjBDVHJMZ1lCUHJ0N0Q4CkZyUG9PclNQS1FTbTJySG1WQkRG
Vm1RdE0wN1BDc0YwdUtmYzBneGZHWVEKLS0tIDg5NzczYkR5YkpldUFtNHRUekxk
eUxHY2NTM09kRXQ1L3pkd3MwTlhTU0UKlNWwpdlqqwo7r3+gUZN4GJ3Qarr0DkFS
XUJApSBn8vYCG02BRGDBkKlm6KdqspHl36fgsB2pmtZm+oTIhG+1OQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj
lastmodified: "2026-08-17T04:51:05Z"
mac: ENC[AES256_GCM,data:Eoha31ZU3Rh4+BynONCTvz8/2PWcJ2bb8FQN1DlADnay2RcNKTa2rKkdipINLmVsMDV6BlWj+4RgQP2aVEL7XJmMVPjNKlun6tMClsT69NvJIYvlw9HbfEpXdw8U8SsNNATB2cyQ9Imayu+Zb2IWa/4GnzUUfe3PQai3rih3Yt8=,iv:0Ejixjh4A0Op/0+UpQdcsXktwwtXQoweYrPEqfVQwB0=,tag:ln4E7z6LJX2QV8rGTXIW/A==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3

View File

@@ -1,6 +1,6 @@
services:
maintenant:
image: ghcr.io/kolapsis/maintenant:1.3.5
image: ghcr.io/kolapsis/maintenant:1.3.9
container_name: maintenant
restart: unless-stopped
read_only: true

View File

@@ -0,0 +1,233 @@
# Matrix — matrix.poneyworld.net
Homeserver Synapse et ses satellites. Huit conteneurs :
| Service | Rôle | Exposé sur |
|---|---|---|
| `synapse` | le homeserver | `synapse.poneyworld.net` (fédération) |
| `db` | PostgreSQL 18 | — |
| `redis` | Valkey, cache interne de Synapse (bloc `redis:` du homeserver) | — |
| `nginx` | sert `/_matrix` (vers `synapse:8008`) et `/.well-known/matrix/` | `matrix.poneyworld.net` (API client) |
| `web` | Element Web | `chat.poneyworld.net` |
| `livekit` | SFU pour Element Call | `livekit.opytex.org` |
| `lk-jwt` | délivre les jetons LiveKit à Element | `livekit.opytex.org/sfu/get`, `/get_token`, `/healthz` |
| `coturn` | serveur TURN (appels 1-à-1) | ports 3478 / 5349 / 49160-49200 |
Deux entrées différentes, à ne pas confondre : `matrix.poneyworld.net` est le
`server_name` et l'API client, `synapse.poneyworld.net` est la cible de
délégation annoncée par `/.well-known/matrix/server`, empruntée par les
serveurs fédérés. Les deux sont vivantes.
## Déploiement
Depuis ce dossier, sur chioggia :
```bash
sops exec-env secrets.env.yaml 'docker compose up -d'
```
Les secrets sont déclarés `${VAR:?}` : **toute** commande `docker compose`
échoue sans l'enveloppe `sops exec-env`, y compris `stop`, `logs`, `ps` et
`exec`. Pour en enchaîner plusieurs, une seule enveloppe suffit :
```bash
sops exec-env secrets.env.yaml '
docker compose stop synapse &&
docker compose exec -T db psql -U synapse synapse -c "..." &&
docker compose up -d
'
```
Après une rotation de secret, `docker compose up -d` répond « up to date » et
garde l'ancienne valeur. Il faut `--force-recreate` sur le service concerné.
## Le nom de projet
`name: matrix_poneyworld`, en tête du compose, ne suit pas le nom du dossier.
C'est délibéré et c'est porteur : les volumes s'appellent
`matrix_poneyworld_matrix_data` et `matrix_poneyworld_matrix_db`.
**Retirer cette ligne, ou la modifier, rendrait invisibles les médias, les
clés de signature du serveur, `homeserver.yaml` et toute la base — la stack
repartirait sur un homeserver vide, sans la moindre erreur.**
## Où vit vraiment la configuration
Presque rien de la configuration de Synapse n'est dans ce dépôt : elle est
dans `/data/homeserver.yaml`, à l'intérieur du volume `matrix_data`.
Les variables `SYNAPSE_*` de l'`env_file` ne sont lues que par le mode
`generate` de l'image, au tout premier démarrage. Elles sont **inertes au
runtime** : `SYNAPSE_ENABLE_REGISTRATION=yes` y cohabite sans conséquence avec
un `enable_registration: False` effectif dans `homeserver.yaml`. Ne pas s'y
fier pour lire l'état du serveur.
Corollaire pour les secrets : `turn_shared_secret`, `macaroon_secret_key`,
`registration_shared_secret` et le mot de passe de la base sont dans
`homeserver.yaml`, donc **hors SOPS et hors dépôt**. Une rotation se fait des
deux côtés (voir plus bas).
## Secrets
Cinq valeurs dans `secrets.env.yaml`, injectées par `sops exec-env` :
| Variable | Consommé par |
|---|---|
| `POSTGRES_PASSWORD` | `db` |
| `LIVEKIT_KEY`, `LIVEKIT_SECRET` | `livekit` (via `LIVEKIT_KEYS`) et `lk-jwt` |
| `TURN_STATIC_AUTH_SECRET`, `TURN_CLI_PASSWORD` | `coturn` |
`LIVEKIT_KEY` tient plus de l'identifiant que du secret. Il est chiffré avec
son secret parce que les deux se tournent toujours ensemble : les séparer sur
deux mécanismes rendrait la rotation facile à ne faire qu'à moitié.
```bash
sops edit secrets.env.yaml
```
### Deux mécanismes différents pour deux logiciels
Ni coturn ni LiveKit ne lisent une variable d'environnement depuis leur
fichier de configuration — un fichier monté n'est pas interpolé par Compose.
C'est le même problème que la clé LAPI de CrowdSec sur Traefik, mais les deux
n'offrent pas la même sortie.
**LiveKit** accepte la paire par variable d'environnement : `LIVEKIT_KEYS`, au
format `clé: secret`. `livekit.yaml` n'a donc pas de bloc `keys:`, et la
valeur est composée dans `environment:` du compose, où l'interpolation
`${...}` s'applique. `lk-jwt` consomme les deux mêmes variables.
**coturn** n'offre rien d'équivalent : pas d'interpolation dans
`turnserver.conf`, pas d'option `...File`, pas de lecture d'environnement. La
seule voie sans bricolage est la ligne de commande :
```yaml
command:
- -c
- /etc/coturn/turnserver.conf
- --static-auth-secret=${TURN_STATIC_AUTH_SECRET:?}
- --cli-password=${TURN_CLI_PASSWORD:?}
```
Le compromis est là : les deux valeurs sont visibles dans `docker inspect
coturn` et dans la table des processus de l'hôte. Un secret Compose ferait
mieux, coturn ne sait pas le lire.
`turnserver.conf` ne doit jamais porter de ligne `static-auth-secret=` ou
`cli-password=` : ce serait un secret en clair dans le dépôt, et une seconde
source concurrente de celle du `command:`.
### Rotation
`turn_shared_secret` ne se change **pas seul** : la même valeur doit être
identique dans `homeserver.yaml` (Synapse la signe) et côté coturn (il la
vérifie). Deux valeurs différentes ne produisent aucune erreur au démarrage —
les appels échouent simplement à établir un relais.
```bash
sops edit secrets.env.yaml # TURN_STATIC_AUTH_SECRET
# homeserver.yaml s'édite depuis l'hôte : l'image Synapse n'a pas d'éditeur.
sudo vi "$(docker volume inspect matrix_poneyworld_matrix_data \
--format '{{.Mountpoint}}')/homeserver.yaml" # turn_shared_secret
sops exec-env secrets.env.yaml '
docker compose up -d --force-recreate coturn &&
docker compose restart synapse
'
```
`macaroon_secret_key` (dans `homeserver.yaml`) signe les jetons d'accès : le
changer **déconnecte tous les utilisateurs**, sur tous leurs appareils. Ce
n'est pas une opération anodine, c'est une opération annoncée.
`registration_shared_secret` (dans `homeserver.yaml`) n'est utilisé que par
`register_new_matrix_user`. Il se change librement.
`POSTGRES_PASSWORD` ne se change pas seul non plus : PostgreSQL ignore la
variable sur un `PGDATA` déjà initialisé. Il faut un `ALTER USER` dans la base
**et** la même valeur dans le bloc `database:` de `homeserver.yaml`.
## Volumes et sauvegarde
| Volume | Contenu | Sauvegarde |
|---|---|---|
| `matrix_poneyworld_matrix_data` | médias, clé de signature du serveur, `homeserver.yaml` | label `backup.enable=true` + `MANUAL_VOLUMES` |
| `matrix_poneyworld_matrix_db` | base PostgreSQL | dump SQL (`backup.type=postgres` sur `db`) |
Les labels de volume ne s'appliquent qu'à la création : en ajouter 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**. Ne pas toucher au
bloc `volumes:` du compose.
### `POSTGRES_DB` conditionne le dump
`backup_docker.sh` lit la base à dumper dans l'environnement du conteneur
`db` :
```bash
db_name=$(docker exec "$container" printenv POSTGRES_DB 2>/dev/null || echo "postgres")
```
Sans `POSTGRES_DB`, le repli s'applique : le script dumpe la base système
`postgres`, vide, et journalise un succès. `POSTGRES_DB=synapse` dans
l'`env_file` n'a pas d'autre fonction que d'être visible par ce `printenv`
PostgreSQL, lui, ne lit cette variable qu'à l'initialisation du cluster.
Vérification, après tout changement touchant `db` :
```bash
zcat /other/backups/db-dumps/matrix_poneyworld.sql.gz | grep -c "CREATE TABLE"
```
Un `0` signifie que la sauvegarde est vide.
## L'IP publique est écrite en dur, à deux endroits
`coturn/turnserver.conf` (`external-ip`) et `livekit/livekit.yaml` (`node_ip`)
portent tous deux l'adresse publique de chioggia, `188.165.240.182`. Les deux
services tournent en bridge : ils ne voient qu'une IP privée et ne peuvent pas
deviner celle qu'il faut annoncer aux clients.
**Si la machine change d'adresse, ces deux fichiers doivent changer avec
elle.** Aucun des deux ne le signalera : coturn et LiveKit démarrent
normalement en annonçant une adresse erronée, et seuls les appels échouent —
sans erreur dans les logs, puisque du point de vue des serveurs tout va bien.
## Le chemin des requêtes clientes
`matrix.poneyworld.net` passe par Traefik, puis nginx, puis Synapse. Deux
conséquences à ne pas défaire :
- nginx doit propager la chaîne avec `$proxy_add_x_forwarded_for`, jamais
`$remote_addr` : ce dernier vaut l'adresse de **Traefik**, et l'utiliser
écrase l'en-tête posé en amont. Synapse voit alors tous les clients sous une
seule adresse — la limitation de débit à la connexion devient un compteur
partagé par tous les comptes, et les journaux d'authentification perdent leur
intérêt. Côté Synapse, le listener 8008 doit porter `x_forwarded: true`.
- `crowdsec@file` est posé sur les routeurs `matrix_nginx` (API client) et
`element` (interface web), **délibérément pas sur `synapse`** : ce dernier
sert la fédération, qui arrive de milliers de serveurs distants. Une décision
de bannissement à cet endroit couperait les correspondants.
Contrôle de la chaîne, une seule adresse en `172.x` signalant le problème :
```sql
SELECT ip, count(*) FROM user_ips GROUP BY ip ORDER BY count DESC LIMIT 10;
```
## Versions d'images
Toutes les images sont épinglées au patch. Les tags d'amont ne suivent pas la
même forme : `livekit-server` préfixe d'un `v`, `lk-jwt-service` non — bien
que son tag *git* le porte. Le label OCI
`org.opencontainers.image.version` du conteneur donne la forme du tag d'image,
c'est lui qui fait foi.
`livekit` et `lk-jwt` se montent ensemble : ils s'authentifient mutuellement et
suivent le même protocole MatrixRTC.
Références :
- https://element-hq.github.io/synapse/latest/setup/turn/coturn.html
- https://docs.livekit.io/home/self-hosting/deployment/

View File

@@ -0,0 +1,14 @@
# Serveur TURN de matrix.poneyworld.net.
#
# `static-auth-secret` et `cli-password` ne sont PAS ici : coturn n'interpole
# aucune variable dans ce fichier et n'a pas d'option `...File`. Ils sont
# passés en `command:` par docker-compose.yml, alimentés par SOPS.
use-auth-secret
realm=matrix.poneyworld.net
listening-port=3478
tls-listening-port=5349
min-port=49160
max-port=49200
verbose
allow-loopback-peers
external-ip=188.165.240.182

View File

@@ -0,0 +1,209 @@
# Le nom de projet ne suit PAS le dossier : les volumes existants sont
# préfixés `matrix_poneyworld_`. Retirer cette ligne ferait repartir Synapse
# sur une base et un magasin de médias vides.
name: matrix_poneyworld
services:
synapse:
image: matrixdotorg/synapse:v1.159.0
restart: unless-stopped
security_opt:
- no-new-privileges:true
# Forme longue : `depends_on` court n'ordonne que le DÉMARRAGE, sans
# attendre que la base accepte les connexions.
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- env_file
volumes:
- matrix_data:/data
networks:
- default
- traefik-proxy
- smtp
labels:
- traefik.enable=true
- traefik.docker.network=traefik-proxy
- traefik.http.routers.synapse.entrypoints=web-secure
- traefik.http.routers.synapse.rule=Host(`synapse.poneyworld.net`)
- traefik.http.routers.synapse.tls.certresolver=letsencrypt
- traefik.http.services.synapse.loadbalancer.server.port=8008
db:
image: postgres:18.6
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- env_file
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
POSTGRES_INITDB_ARGS: --encoding=UTF-8 --lc-collate=C --lc-ctype=C
volumes:
- matrix_db:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 15s
timeout: 3s
retries: 20
start_period: 30s
networks:
- default
labels:
backup.enable: true
backup.type: postgres
redis:
image: valkey/valkey:9.1.1
restart: unless-stopped
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 15s
timeout: 3s
retries: 20
networks:
- default
nginx:
image: nginx:1.30.4
restart: unless-stopped
security_opt:
- no-new-privileges:true
# `proxy_pass http://synapse:8008` est résolu au CHARGEMENT de la config :
# sans ce depends_on, nginx s'arrête sur « host not found in upstream » au
# démarrage de la machine et ne revient que par la boucle `restart`.
depends_on:
synapse:
condition: service_healthy
volumes:
- ./nginx/matrix.conf:/etc/nginx/conf.d/matrix.conf:ro
- ./nginx/www:/var/www/:ro
networks:
- default
- traefik-proxy
labels:
- traefik.enable=true
- traefik.docker.network=traefik-proxy
- traefik.http.routers.matrix_nginx.entrypoints=web-secure
- traefik.http.routers.matrix_nginx.rule=Host(`matrix.poneyworld.net`)
- traefik.http.routers.matrix_nginx.tls.certresolver=letsencrypt
- traefik.http.routers.matrix_nginx.middlewares=crowdsec@file
- traefik.http.services.matrix_nginx.loadbalancer.server.port=80
web:
image: vectorim/element-web:v1.12.26
restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes:
- ./element/config.json:/app/config.json:ro
networks:
- default
- traefik-proxy
labels:
- traefik.enable=true
- traefik.docker.network=traefik-proxy
- traefik.http.routers.element.entrypoints=web-secure
- traefik.http.routers.element.rule=Host(`chat.poneyworld.net`)
- traefik.http.routers.element.tls.certresolver=letsencrypt
- traefik.http.routers.element.middlewares=crowdsec@file
- traefik.http.services.element.loadbalancer.server.port=80
livekit:
image: livekit/livekit-server:v1.13.5
command: --config /etc/livekit.yaml
restart: unless-stopped
security_opt:
- no-new-privileges:true
environment:
# `keys:` a été retiré de livekit.yaml : la paire arrive ici, où
# l'interpolation ${...} de Compose s'applique.
LIVEKIT_KEYS: "${LIVEKIT_KEY:?}: ${LIVEKIT_SECRET:?}"
volumes:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
ports:
- "7881:7881/tcp"
- "50100-50200:50100-50200/udp"
networks:
- default
- traefik-proxy
labels:
- traefik.enable=true
- traefik.docker.network=traefik-proxy
- traefik.http.routers.livekit.entrypoints=web-secure
- traefik.http.routers.livekit.rule=Host(`livekit.opytex.org`)
- traefik.http.routers.livekit.tls.certresolver=letsencrypt
- traefik.http.services.livekit.loadbalancer.server.port=7880
lk-jwt:
image: ghcr.io/element-hq/lk-jwt-service:0.6.0
restart: unless-stopped
security_opt:
- no-new-privileges:true
depends_on:
- livekit
environment:
# Pas de LIVEKIT_JWT_BIND : le healthcheck de l'image assemble
# `http://localhost:` + la valeur, ce qui donne un double deux-points et
# le fait échouer. Le défaut (:8080) est le seul réglage qui fonctionne.
LIVEKIT_URL: wss://livekit.opytex.org
LIVEKIT_KEY: ${LIVEKIT_KEY:?}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:?}
LIVEKIT_FULL_ACCESS_HOMESERVERS: matrix.poneyworld.net
networks:
- default
- traefik-proxy
labels:
- traefik.enable=true
- traefik.docker.network=traefik-proxy
- traefik.http.routers.lkjwt.entrypoints=web-secure
- traefik.http.routers.lkjwt.rule=Host(`livekit.opytex.org`) && (PathPrefix(`/sfu/get`) || PathPrefix(`/get_token`) || PathPrefix(`/healthz`))
- traefik.http.routers.lkjwt.tls.certresolver=letsencrypt
- traefik.http.services.lkjwt.loadbalancer.server.port=8080
coturn:
image: coturn/coturn:4.17.2
restart: unless-stopped
security_opt:
- no-new-privileges:true
# Les deux secrets ne peuvent pas venir du fichier de conf : coturn n'y
# interpole rien et n'offre aucune option `...File`. Voir README.md.
command:
- -c
- /etc/coturn/turnserver.conf
- --static-auth-secret=${TURN_STATIC_AUTH_SECRET:?}
- --cli-password=${TURN_CLI_PASSWORD:?}
volumes:
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
# `verbose` dans turnserver.conf journalise chaque allocation et chaque
# permission : sans borne, le fichier json croît sans fin.
logging:
driver: "json-file"
options:
max-size: "1m"
# 3478 doit être publié en UDP autant qu'en TCP : un client TURN tente
# l'UDP en premier. 5349 (TURNS) reste en TCP seul, c'est du TLS.
ports:
- "3478:3478"
- "3478:3478/udp"
- "5349:5349"
- "49160-49200:49160-49200/udp"
networks:
- default
volumes:
matrix_data:
labels:
backup.enable: true
matrix_db:
networks:
traefik-proxy:
external: true
smtp:
external: true

View File

@@ -0,0 +1,65 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.poneyworld.net",
"server_name": "matrix.poneyworld.net"
},
"m.identity_server": {
"base_url": "https://vector.im"
}
},
"disable_custom_urls": false,
"disable_guests": false,
"disable_login_language_selector": false,
"disable_3pid_login": false,
"brand": "Element",
"integrations_ui_url": "https://scalar.vector.im/",
"integrations_rest_url": "https://scalar.vector.im/api",
"integrations_widgets_urls": [
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
],
"bug_report_endpoint_url": "https://riot.im/bugreports/submit",
"defaultCountryCode": "FR",
"showLabsSettings": false,
"features": {
"feature_new_spinner": "labs",
"feature_pinning": "labs",
"feature_custom_status": "labs",
"feature_custom_tags": "labs",
"feature_state_counters": "labs"
},
"default_federate": true,
"default_theme": "light",
"roomDirectory": {
"servers": [
"matrix.org"
]
},
"welcomeUserId": "@riot-bot:matrix.org",
"piwik": {
"url": "https://piwik.riot.im/",
"whitelistedHSUrls": [
"https://matrix.org"
],
"whitelistedISUrls": [
"https://vector.im",
"https://matrix.org"
],
"siteId": 1
},
"enable_presence_by_hs_url": {
"https://matrix.org": false,
"https://matrix-client.matrix.org": false
},
"settingDefaults": {
"breadcrumbs": true
},
"element_call": {
"url": "https://call.element.io",
"use_exclusively": true
}
}

View File

@@ -0,0 +1,21 @@
# Synapse — configuration non sensible.
#
# La configuration réelle du homeserver n'est PAS ici : elle vit dans
# /data/homeserver.yaml, à l'intérieur du volume matrix_data, hors dépôt.
# Les variables SYNAPSE_* ci-dessous ne sont lues que par le mode `generate`
# de l'image, au tout premier démarrage. Elles sont inertes au runtime :
# SYNAPSE_ENABLE_REGISTRATION=yes cohabite ainsi avec un
# `enable_registration: False` effectif dans homeserver.yaml.
SYNAPSE_HOST=synapse.poneyworld.net
SYNAPSE_NO_TLS=true
SYNAPSE_REPORT_STATS=no
SYNAPSE_ENABLE_REGISTRATION=yes
PROTOCOL=http
# Lues par le conteneur `db`, et par le script de sauvegarde qui fait
# `docker exec printenv POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD`.
# Sans POSTGRES_DB, le script retombe sur `postgres` et dumpe une base vide
# en signalant un succès. Voir README.md.
# POSTGRES_PASSWORD n'est pas ici, il vient de SOPS.
POSTGRES_USER=synapse
POSTGRES_DB=synapse

View File

@@ -0,0 +1,13 @@
port: 7880
bind_addresses:
- "0.0.0.0"
rtc:
tcp_port: 7881
# udp_port: 7882
port_range_start: 50100
port_range_end: 50200
#use_external_ip: true
enable_loopback_candidate: false
node_ip: 188.165.240.182
# `keys:` est absent volontairement : la paire clé/secret arrive par la
# variable d'environnement LIVEKIT_KEYS, alimentée par SOPS.

View File

@@ -0,0 +1,20 @@
server {
listen 80 default_server;
server_name matrix.poneyworld.net;
# Traefik -> nginx -> synapse
location /_matrix {
proxy_pass http://synapse:8008;
# $remote_addr vaut ici l'adresse de Traefik, pas celle du client : l'utiliser
# ÉCRASE l'en-tête posé par Traefik et rend tous les clients identiques aux
# yeux de Synapse. $proxy_add_x_forwarded_for complète la chaîne existante.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 128m;
}
location /.well-known/matrix/ {
root /var/www/;
default_type application/json;
add_header Access-Control-Allow-Origin *;
}
}

View File

@@ -0,0 +1,11 @@
{
"m.homeserver": {
"base_url": "https://matrix.poneyworld.net"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://livekit.opytex.org"
}
]
}

View File

@@ -0,0 +1,3 @@
{
"m.server": "synapse.poneyworld.net:443"
}

View File

@@ -0,0 +1,29 @@
POSTGRES_PASSWORD: ENC[AES256_GCM,data:vi0nbg1K7psZLvH+ELKJ3uujukcpKL+QYUMHwQhZwED8ZViUZWgMANN8SFjwNJP8RHpgQEQbFjzx2vHuJ3GL1Q==,iv:4OtypnPBzZj6BEVD63ijyD8rufcXAgXO23Hd+KUxXDg=,tag:ctcDhm/+WDhOnBrsO7Fh5A==,type:str]
LIVEKIT_KEY: ENC[AES256_GCM,data:d27blhJhDOWnKaMhZOXHBg5N7Xk=,iv:mLjwF8teEeEeJV/DORM5+teNq+bkiVxNmJ797Wnk/Qw=,tag:GjMPv+oyaFFVgf0k2u6hAA==,type:str]
LIVEKIT_SECRET: ENC[AES256_GCM,data:mQkMHwaIPBn88Xjayky7avqIq1zovJk8xH9+krLe8xiWZmNTHxwkE9LKxRs/aU3rDeUH/y9OjuDy3QDTq/IpxA==,iv:vH4igaayRzDsKjZJVw4VtWsAcyubTgesZcveLrtxwSM=,tag:kTDK29xCmPIFVGhMkr2aVQ==,type:str]
TURN_STATIC_AUTH_SECRET: ENC[AES256_GCM,data:NmgXDsprdI9jP2A4TjHVOv1A2+mnX07zVxYzOrOzU07NiaZIHMYigew/4pMMl2Xz4Rn1K4kFAkXqN/Qr/V+5Rw==,iv:DT5I+u4f8dG2CipxY7f71sGkmmJqre6A9WQPnbxrLxw=,tag:eJ959nOZngMCxvzsmqMOuA==,type:str]
TURN_CLI_PASSWORD: ENC[AES256_GCM,data:P1kj2dxT+BBHOWE3zqw82q/w8jrxIjZ1KCQbB05yWW2WjKBBvi1bP041E6sx1Xo7FOS035IX4dUcNo5wpS8Uzg==,iv:VDuQP8EXfc664cjMJJ7NOpvQhkKZF6BKKrTxfBRwjeQ=,tag:pblrz5eboJlz7dyjLqRv3w==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIL3JiNjB6Zkt0MWREWlp6
RDYvQjRzSXJaWWJEWkpROU56YzhVTE9jYzBNCmE4dEt3cDNncWhQYlFqRFlVVHVL
cGJ0eVo3WEFSVUtNcTJKbENtN1dBeE0KLS0tIDNzVW1hUlpQYmFEMUxyb3hWSUNl
TTN6YjFabjZ6QTVtZ3Nwc01WbEs1ckkKD/uwrElZd6/xNbyO/RdPegbDDaS0Kler
0CJwZRBumyLirDMVDOMTB8uJqqwCQLppGL8yLsZFMUaNdR3GJpLmFw==
-----END AGE ENCRYPTED FILE-----
recipient: age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOYjYwNTE0RVdQMm5wOHpv
RnVrOXRWTUQ0MjRuQlI3cU5hMWNLaGdQb21zCmxzS3lzRjgybWxVQ2xGTStKbVRx
V1RFREY1TVJzVTl2Rk5ZQ0QrY3U3ZVUKLS0tIGMvUUZpd01ROWhuRTh6RWdNS0V5
K0Zwd0NMWGJKUnJyaEZDMlM1SENzQ28K4tbrrogUL9hLBM49FIT6cQLxyG5/dqj8
fifz17xNS4S2YRCOVNal+AI4JXM9yw9hcoB4tt3ileEYcVYl9hzcHg==
-----END AGE ENCRYPTED FILE-----
recipient: age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj
lastmodified: "2026-08-19T16:33:04Z"
mac: ENC[AES256_GCM,data:Qh5v9F/F24goNXWwinfZskdtRGo5EY1eYwmKoBHb33wdrfQ+wTPjS1XaBnzkxOB9iqk5yna9GTwdqjaZSG/g4KgJRHUQpo+EQieuJn6LDK9YXSCyUGVwQd1ZeHwYypzJLb6JQ+qFwnYnZqOcFGIBGG2E8LFxU7+dFMdRGqn05eo=,iv:nfgJrbMvBQIUjGZoJHNjtv+Uy8fUfjQhxZ9zKcZShnI=,tag:OYAygJJRUkU0QHV7Y6lE7Q==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3

View File

@@ -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/

View File

@@ -0,0 +1,141 @@
# Paperless-ngx — paperless.opytex.org
Gestion documentaire (OCR + indexation). Cinq conteneurs : `webserver`
(l'application), `db` (PostgreSQL), `broker` (Valkey, 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_valkeydata`
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'
```
Les secrets sont déclarés `${VAR:?}` dans le compose : **toute** commande
`docker compose` échoue sans l'enveloppe `sops exec-env`, y compris `stop`,
`exec`, `logs` et `ps`. Pour enchaîner plusieurs commandes, une seule
enveloppe suffit :
```bash
sops exec-env secrets.env.yaml '
docker compose stop webserver &&
docker compose exec -T db psql -U paperless -d paperless -c "..." &&
docker compose up -d
'
```
## 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`
| Volume | Rôle |
|---|---|
| `consume` | tout fichier déposé ici est ingéré puis supprimé |
| `export` | destination de `document_exporter` |
Ce sont des volumes Docker et non des dossiers de ce dépôt : les données
restent hors de l'arbre versionné, et un déplacement du dépôt ne les emmène
pas. Ils naissent avec l'UID/GID du chemin correspondant dans l'image (1000),
donc sans `chown` à faire.
Déposer un fichier à ingérer passe par l'UI, ou :
```bash
docker cp fichier.pdf paperlessopytexorg-webserver-1:/usr/src/paperless/consume/
```
Générer une archive lisible sans Paperless :
```bash
sops exec-env secrets.env.yaml \
'docker compose exec webserver document_exporter ../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_valkeydata` | file de tâches Celery (Valkey) | non sauvegardé, volontairement — reconstruit |
| `paperlessopytexorg_export` | archive `document_exporter`, lisible sans Paperless | label `backup.enable=true` sur le volume |
| `paperlessopytexorg_consume` | sas d'ingestion | non sauvegardé, volontairement — transitoire |
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.
Toutes les images sont épinglées au patch, y compris `apache/tika` que la
documentation amont laisse en `latest` : un tag flottant rend la stack non
reproductible et fait franchir des versions au prochain `pull`, sans que rien
ne l'annonce.
Références :
- https://docs.paperless-ngx.com/configuration/
- https://docs.goauthentik.io/integrations/services/paperless-ngx/

View File

@@ -0,0 +1,126 @@
services:
broker:
image: docker.io/valkey/valkey:9.1.1-alpine
restart: unless-stopped
volumes:
- valkeydata:/data
networks:
- default
db:
image: docker.io/library/postgres:16.15
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:3.0.5
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
- 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.34.0
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:3.3.1.0
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
# Volume neuf : Valkey 9 (fork de Redis 7.2) ne sait pas lire un `dump.rdb`
# écrit par Redis 7.4. Le contenu n'étant que la file Celery, on repart de
# zéro plutôt que de convertir. L'ancien `paperlessopytexorg_redisdata`
# subsiste, inutilisé : c'est le chemin de retour vers `redis:7`.
valkeydata:
# Volumes neufs : le label s'applique donc, contrairement aux quatre
# au-dessus. `consume` n'en porte pas — un fichier n'y séjourne que le temps
# d'être ingéré, après quoi il est dans `media`.
export:
labels:
backup.enable: true
consume:
networks:
traefik-proxy:
external: true

View File

@@ -0,0 +1,62 @@
# =============================================================================
# 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
# --- Requis à partir de la v3 ---
# Le moteur n'est plus déduit de PAPERLESS_DBHOST. La valeur attendue est
# `postgresql` et non `postgres`.
PAPERLESS_DBENGINE=postgresql
# La v3 accepte les doublons par défaut ; ceci restaure le refus, qui était le
# comportement de la v2.
PAPERLESS_CONSUMER_DELETE_DUPLICATES=true

View File

@@ -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

View File

@@ -0,0 +1,52 @@
# FreshRSS — rss.opytex.org
Agrégateur RSS, PostgreSQL dédié, authentification SSO via Authentik.
## Déploiement
Depuis ce dossier, sur chioggia :
```bash
sops exec-env secrets.env.yaml 'docker compose up -d'
```
Le nom du dossier (`rss.opytex.org`) donne le nom de projet Compose
`rssopytexorg`, dont dérivent les noms de volumes
(`rssopytexorg_freshrss_data`, …) et le nom du dump de base
(`rssopytexorg.sql.gz`). **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'
```
## Volumes
| Volume | Contenu | Sauvegarde |
| ---------------------------------- | -------------------------------- | -------------------------------------------------- |
| `rssopytexorg_pgsql_data` | base PostgreSQL | dump SQL (`backup.type=postgres` sur le conteneur) |
| `rssopytexorg_freshrss_data` | config, comptes, articles locaux | label `backup.enable=true` sur le volume |
| `rssopytexorg_freshrss_extensions` | extensions installées | label `backup.enable=true` sur le volume |
Les deux volumes FreshRSS portent `backup.enable=true` **depuis leur
création** : le label est donc conservé dans le compose. Le retirer, comme
l'ajouter à un volume qui ne l'a pas, ferait diverger la déclaration du volume
réel — Compose proposerait alors de le recréer, donc de vider FreshRSS.
Ils figurent en plus dans `MANUAL_VOLUMES` (`backup.conf`), ce qui les fait
sauvegarder deux fois : le script empile les chemins de `MANUAL_VOLUMES` puis
ceux des volumes labellisés, sans dédoublonner. Redondance héritée de l'ancien
dépôt, sans conséquence (restic déduplique) — à nettoyer quand `backup.conf`
sera migré dans `ops/backup/`.
`pgsql_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.
Références :
- https://freshrss.github.io/FreshRSS/en/admins/16_OpenID-Connect-Authentik.html
- https://docs.goauthentik.io/integrations/services/freshrss/

View File

@@ -0,0 +1,53 @@
services:
postgresql:
image: postgres:16.15
container_name: freshrss-db
restart: unless-stopped
env_file:
- env_file
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
volumes:
- pgsql_data:/var/lib/postgresql/data
networks:
- default
labels:
- backup.enable=true
- backup.type=postgres
freshrss:
image: freshrss/freshrss:1.29.1
restart: unless-stopped
depends_on:
- postgresql
env_file:
- env_file
environment:
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?}
volumes:
- freshrss_data:/var/www/FreshRSS/data
- freshrss_extensions:/var/www/FreshRSS/extensions
networks:
- default
- traefik-proxy
labels:
- traefik.enable=true
- traefik.docker.network=traefik-proxy
- traefik.http.routers.freshrss.entrypoints=web-secure
- traefik.http.routers.freshrss.rule=Host(`rss.opytex.org`)
- traefik.http.routers.freshrss.tls.certresolver=letsencrypt
- traefik.http.routers.freshrss.middlewares=crowdsec@file
- traefik.http.services.freshrss.loadbalancer.server.port=80
volumes:
pgsql_data:
freshrss_data:
labels:
backup.enable: true
freshrss_extensions:
labels:
backup.enable: true
networks:
traefik-proxy:
external: true

View File

@@ -0,0 +1,26 @@
# =============================================================================
# FRESHRSS — configuration NON sensible
# =============================================================================
# POSTGRES_PASSWORD et OIDC_CLIENT_SECRET sont dans secrets.env.yaml et
# injectés par `sops exec-env`.
#
# Ce fichier ne contient QUE ce que les conteneurs consomment. Le domaine et
# la version d'image sont en dur dans docker-compose.yml : `env_file:`
# n'alimente pas l'interpolation ${...}, et une version en dur est détectée
# par Renovate.
# =============================================================================
# --- Base de données ---
POSTGRES_USER=freshrss
POSTGRES_DB=freshrss
# --- Conteneur FreshRSS ---
CRON_MIN=*/20
TZ=Europe/Paris
# --- SSO OpenID Connect (Authentik) ---
OIDC_ENABLED=1
OIDC_PROVIDER_METADATA_URL=https://sso.opytex.org/application/o/freshrss/.well-known/openid-configuration
OIDC_CLIENT_ID=3YF3MIapyVz45EQ8BcFGwk2q47BTstmPMhRwwPlZ
OIDC_X_FORWARDED_HEADERS="X-Forwarded-Port X-Forwarded-Proto X-Forwarded-Host"
OIDC_SCOPES="openid email profile"

View File

@@ -0,0 +1,26 @@
POSTGRES_PASSWORD: ENC[AES256_GCM,data:2QAKvkuKOxME53FF3YGugKZSxVjfgF4NEXRMFNmSP6Z9ICkUHIQvFlxcFCKIJ1L8,iv:shO6GH4mlqPpEXO9Q/0LgUb7l/zDOghDbTxMzlkJBjg=,tag:vlBr1hE4GimoobZ8+ejgrg==,type:str]
OIDC_CLIENT_SECRET: ENC[AES256_GCM,data:5dlCxqpNV7KVrMAP4RrSynI3Vd0SdlbaUxZXO4d7Tc315iMqLP3pyUWKc7qHdgQeWeOnX1nq3S0IcFwdifmGi87InkBkxorvKCIC6IK6yxm46OOVuYlX39KPVMpIPgX857sPk/7wRp9CHTcvyTIE0s1tspZZX3qe693e+oso0Oo=,iv:GTSQ0G3la61BeNDrMaHeYMZ7Xk+iGuQySyNCKeao1vo=,tag:t6YGToLryI7CvASr8crwiA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNUS9xaUMxV2EreEVwdmFV
c1kzbkNOOCt2WisvQjhvU0wxb0hSUkRkMUVRCnFxUmNXNUhoemRKWjlpN2E0Z0kz
cWorU3ZINm5ISEhMWWRsVVFJRUFqZ1EKLS0tIGVMZUloa2RxK0daWHVFNW1CSzZZ
NVhNZUxjcmZTYTk5UjNZUmFrRnlXeW8KQVe3zJMofheTzg6vDtKSEYefRFz9ND9v
CPTWmjv0tGAsWX6sdnKeBniw29A9QRKlAKubvES7hl4cf2vEtruYzQ==
-----END AGE ENCRYPTED FILE-----
recipient: age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsZEVuejgwMXhCV1BCaHlp
RG5TZklLa0xQYWVTTU40VVhOSEVsb2xHZXlVClJiYXhtZ2ZCRklRTGRGSVBtaUFP
VXFjME9WZHhyRXJDazR4VnV2R0VXaWMKLS0tIE9LcmZjMFFhRWJqbU52S1BHSldx
Wk8wcis2bzgwSWpFVTUwOC9mT3UzVjQK4X6/iYoec/k1r/yLIQoT0XhfYryJ75KN
X7aDRlHniIPPPUg9tdIjGIUKpRBidOBw7s1PYImxOMTqjs0CUoIRzw==
-----END AGE ENCRYPTED FILE-----
recipient: age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj
lastmodified: "2026-08-17T13:14:12Z"
mac: ENC[AES256_GCM,data:FcSRI1QAG7k/yXrx42HOwhbtTLKGl72z7p999qoVZxVkXVNaCpdU4KE3upK27ADJ/8LGCNT1/pbsHRyrxcOZXJrAc2/zC8ZEXWdJdKvV4R/pr2dH/ESZKi4wYGuvzTFbjP0HoAuSRQchSRaogWCGbMpEk2vtE22CkdOZD92T2n8=,iv:k7e5MpMnSp6bcks0cjI76NP75yjwW3KEyZxiw0k1FVs=,tag:0xDH+8/PV+UzLMLYcrTz4w==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3

View File

@@ -0,0 +1,42 @@
services:
traefik:
image: traefik:v3.6
container_name: traefik2
restart: always
# Pas de `command:` : Traefik lit /etc/traefik/traefik.toml par défaut.
# Un `--log.level` ici écraserait silencieusement celui du toml.
ports:
- "80:80"
- "443:443"
volumes:
- ./traefik.toml:/etc/traefik/traefik.toml:ro
- ./dynamic:/etc/traefik/dynamic:ro
# Doit exister comme FICHIER avant le premier `up`, sinon Docker crée un
# répertoire à sa place et Traefik redemande tous les certificats.
- ./acme.json:/acme.json
- traefik_log:/var/log/traefik/
- /var/run/docker.sock:/var/run/docker.sock:ro
secrets:
- crowdsec_lapi_key
networks:
- traefik-proxy
labels:
- traefik.enable=true
- traefik.docker.network=traefik-proxy
- traefik.http.routers.traefik_secure.entrypoints=web-secure
- traefik.http.routers.traefik_secure.rule=Host(`chioggia.opytex.org`)
- traefik.http.routers.traefik_secure.tls.certresolver=letsencrypt
- traefik.http.routers.traefik_secure.service=api@internal
- traefik.http.routers.traefik_secure.middlewares=authentik@file
secrets:
crowdsec_lapi_key:
environment: CROWDSEC_LAPI_KEY
volumes:
traefik_log:
external: true
networks:
traefik-proxy:
external: true

View File

@@ -0,0 +1,18 @@
http:
middlewares:
authentik:
forwardAuth:
address: http://authentik:9000/outpost.goauthentik.io/auth/traefik
trustForwardHeader: true
authResponseHeaders:
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
- X-authentik-jwt
- X-authentik-meta-jwks
- X-authentik-meta-outpost
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version

View File

@@ -0,0 +1,18 @@
http:
middlewares:
crowdsec:
plugin:
bouncer:
enabled: true
crowdsecMode: stream
crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec:8080
# La clé elle-même est dans secrets.env.yaml. Le provider fichier de
# Traefik ne substitue aucune variable d'environnement : c'est ce
# `...File` qui permet de garder ce fichier versionnable en clair.
crowdsecLapiKeyFile: /run/secrets/crowdsec_lapi_key
logLevel: ERROR
# crowdsecAppsecEnabled: true
# crowdsecAppsecHost: crowdsec:7422
# crowdsecAppsecFailureBlock: false
# crowdsecAppsecUnreachableBlock: false

View File

@@ -0,0 +1,28 @@
[http]
[http.routers]
[http.routers.opytexorg]
entryPoints = ["web-secure"]
service = "opytexorg"
middlewares = ["opytexorgstripprefix@file", "opytexorgindex@file", "opytexerror@file"]
rule = "Host(`opytex.org`)"
[http.routers.opytexorg.tls]
certResolver = "letsencrypt"
[[http.routers.opytexorg.tls.domains]]
main = "opytex.org"
[http.services]
[http.services.opytexorg.loadBalancer]
[[http.services.opytexorg.loadBalancer.servers]]
url = "http://minio:9000"
[http.middlewares]
[http.middlewares.opytexorgindex.replacePathRegex]
regex = "^(.*)/$"
replacement = "$1/index.html"
[http.middlewares.opytexorgstripprefix.addPrefix]
prefix = "/opytex.org"
[http.middlewares.opytexerror.errors]
status = ["400-499", "500-599"]
service = "opytexorg@file"
query = "opytex.org/404.html"

View File

@@ -0,0 +1,22 @@
[http]
[http.routers]
[http.routers.raw]
entryPoints = ["web-secure"]
service = "raw"
middlewares = ["rawstripprefix@file"]
rule = "Host(`raw.opytex.org`)"
[http.routers.raw.tls]
certResolver = "letsencrypt"
[[http.routers.raw.tls.domains]]
main = "raw.opytex.org"
[http.services]
[http.services.raw.loadBalancer]
[[http.services.raw.loadBalancer.servers]]
url = "http://minio:9000"
[http.middlewares]
[http.middlewares.rawstripprefix.addPrefix]
prefix = "/raw"

View File

@@ -0,0 +1,25 @@
CROWDSEC_LAPI_KEY: ENC[AES256_GCM,data:p3i4Y2EkE1uygmNy5obsX/5OB0Q6OR1PyQ54PLntmxQOejqSYvMaKwpz7A==,iv:Yl/C/hChLU5Kp4kO0npU+BCnSrsOMR21JPxkWWv+Sx4=,tag:Yh45ejBdOR4aBCMbxfpCxA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkeDlpb3N3amFuTTcwamFy
a0pSTDhTRExLUUxjM1ZIcmU2Rko3enM1dkZNCm9OZHdxcTBxQnB2RzFEbkNOTEJ6
WUdTR3NBeXV3KzlnM2N1YkIvbDJJQUUKLS0tIE9ZQzVKbnZES2tRdkhpUjZjQTdP
Qm1EdGR6TitNaTFVS1pyTGhGK08wbGMK9ZwsDE2ih9EI87MNqFuA65XFZBAL2akF
4ftZM3DDQsvL62DWPYi6t466zg1kH2R2zx/wz9Jil4LKY13vJgz+bQ==
-----END AGE ENCRYPTED FILE-----
recipient: age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlUS9sZXVCLzIwcE4xdXdx
K2R5eTJnTWs3NHRlbEVsMEFhSnJIdGdpS0ZzCmNmVnVqYnlUS09pK25hcFJySnRn
SS81MHpJYksyaFNUeXFEWWFPQXU2R1EKLS0tIHBnZU1OcGZsSnRCM1hIYWlIZ3hK
UGZhSjVRRldXOUI2d3lZNlM1WE9lb2cK1ByWPZ8taCHED0jQiUkldAehZ7KQYPJx
E20o7wnn45LHx1Km+R91CeNTj7NFIXCpLZrhcXo89knvL4qFNCkT+Q==
-----END AGE ENCRYPTED FILE-----
recipient: age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj
lastmodified: "2026-08-14T04:27:43Z"
mac: ENC[AES256_GCM,data:DkOHuAVAZM5f7uDu7BmZtGNWZlOEsU93aWdYw3NzDxcyZji4d1r//OHKtRPyrV02D+FvusU56gjjMkf3B9gKhNjH/vSiQCVbSzDbkbo7sG/YJPFK81IDJs1CAR9zgzTzNj9vRXCZUSvVDkhPh/zKKKkgC8fe5MScLnYZMiVfUt4=,iv:vgI6Hw7Coql1zWvr7jfMMMc538lyG5x9uY2aqr6ZTHc=,tag:EV/fCwtfK+UebeB6390Slg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3

View File

@@ -0,0 +1,54 @@
[global]
checkNewVersion = true
sendAnonymousUsage = false
[log]
level = "INFO"
filePath = "/var/log/traefik/traefik.log"
[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.web.http.redirections.entryPoint]
to = "web-secure"
scheme = "https"
[entryPoints.web-secure]
address = ":443"
[accessLog]
filePath = "/var/log/traefik/access.log"
bufferingSize = 100
[providers]
[providers.file]
directory = "/etc/traefik/dynamic"
[providers.docker]
endpoint = "unix:///var/run/docker.sock"
watch = true
exposedbydefault = false
[api]
dashboard = true
debug = false
# Sans ce bloc, `plugin.bouncer` référencé par dynamic/crowdsec.yml n'existe
# pas : le middleware crowdsec@file devient invalide et TOUS les routeurs qui
# l'utilisent tombent en erreur (404). Traefik ne lit cette configuration qu'au
# démarrage — un conteneur déjà lancé survit à sa disparition, la panne
# n'apparaît qu'à la recréation suivante. C'est arrivé le 2026-08-13.
[experimental]
[experimental.plugins.bouncer]
moduleName = "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
version = "v1.4.5"
[certificatesResolvers.letsencrypt.acme]
email = "benjamin.bertrand@opytex.org"
# Chemin ABSOLU, et monté depuis l'hôte. En relatif, le fichier atterrissait
# dans le répertoire de travail du conteneur : perdu à chaque recréation,
# avec réémission complète des certificats et plafond Let's Encrypt au bout.
storage = "/acme.json"
tlschallenge = true
[certificatesResolvers.letsencrypt.acme.httpChallenge]
entryPoint = "web"