---
title: "Installation et déploiement"
weight: 10
description: "Déployer difuzio : base de données, migrations, keyrings, systemd et intégration Postfix."
---

# Installation et déploiement

Guide opérateur pour déployer difuzio. Trois processus partagent le même binaire
et la même configuration, coordonnés par la base de données. Ce document reprend
et détaille le runbook de référence du dépôt (`docs/runbook/install.md`).

## 1. Base de données

Cette section décrit l'installation avec MariaDB, le backend par défaut. Pour une
petite installation mono-hôte, difuzio se compile aussi avec un backend SQLite (un
simple fichier, aucun serveur de base à administrer) : voir
[Base de données](/difuzio/admin/base-de-donnees) pour choisir, construire le bon
binaire et connaître les limites de chacun.

Créer deux comptes distincts :

- un compte applicatif à moindre privilège (`SELECT`, `INSERT`, `UPDATE`,
  `DELETE` uniquement, aucun DDL). C'est celui qu'utilisent `serve-web`,
  `serve-lmtp` et `worker`.
- un compte de migration à privilèges DDL, fourni uniquement au moment de migrer.
  Le DDL (Data Definition Language) est la famille d'instructions qui définit le
  schéma : `CREATE`, `ALTER`, `DROP`.

Le DSN (Data Source Name) est la chaîne de connexion à la base : compte, hôte,
nom de la base et paramètres du driver. Celui de difuzio doit obligatoirement
porter `parseTime=true`, `loc=UTC`, `time_zone='+00:00'` et
`clientFoundRows=true`. Le drapeau `clientFoundRows` est nécessaire pour que les
`UPDATE` idempotents renvoient la sémantique "lignes correspondantes" attendue
par la couche d'accès.

Appliquer les migrations avant de démarrer les services, avec le compte DDL :

    difuzio migrate up --dsn "<DSN du compte DDL>"

Pour vérifier l'état du schéma à tout moment :

    difuzio migrate status --dsn "<DSN>"

Les migrations sont embarquées dans le binaire (golang-migrate). En cas de schéma
marqué "dirty" après un échec, voir [Référence CLI](/difuzio/admin/cli-reference).

## 2. Secrets (keyrings)

difuzio utilise trois keyrings séparés par durée de vie :

- `session_keyring` (`session.key`) : cookies de session et jetons CSRF. Rotation
  libre, une rotation ne coûte au pire que les sessions en cours.
- `token_keyring` (`token.key`) : tokens single-use (double opt-in, reset de mot
  de passe, invitation, export). Garder l'ancienne version le temps que les liens
  déjà envoyés expirent.
- `verp_keyring` (`verp.key`) : adresses VERP de retour et liens de désabonnement
  one-click. Longue durée de vie, garder l'ancienne version au moins
  `bounce.verp_max_age_days`.

Les trois sont requis par les trois processus (`serve-web`, `serve-lmtp`,
`worker`) et par toute commande qui reçoit `--config`, même si `serve-lmtp` ne
signe que du VERP : la validation de la configuration construit les trois keyrings
sans condition. Aucun secret n'est généré à la volée, l'absence est fatale.

Un quatrième keyring, `credential_keyring` (`credential.key`), est **optionnel** :
il chiffre les comptes de messagerie saisis par liste dans la console (profil
boîte externe, voir [Listes](/difuzio/admin/listes)). Une installation qui n'en
utilise pas démarre sans. Le créer suppose de décommenter la ligne
`LoadCredential=credential.key:...` des **trois** unités de service
(`difuzio-web`, `difuzio-worker@` et `difuzio-lmtp`), sans quoi le chemin
`${CREDENTIALS_DIRECTORY}/credential.key` ne sera pas résolu. `serve-lmtp` ne lit
jamais de compte de messagerie, mais la validation de la configuration construit
tous les keyrings déclarés avant que le processus ne décide de ce dont il a
besoin : la clé déclarée sans la ligne correspondante fait échouer son
démarrage.

### Créer l'utilisateur, les répertoires et les clés

Tous les secrets sur disque vivent dans `/etc/difuzio/secrets/` (0700
`root:root`) : les trois clés de keyring, et le cas échéant les mots de passe
SMTP/IMAP externes. Le répertoire `/etc/difuzio` reste, lui, lisible par le compte
de service pour `config.yaml`.

    useradd --system --home-dir /var/lib/difuzio --shell /usr/sbin/nologin difuzio
    install -d -m 0750 -o root -g difuzio /etc/difuzio
    install -d -m 0700 -o root -g root   /etc/difuzio/secrets
    umask 077
    for k in session token verp; do
      head -c 32 /dev/urandom | base64 > /etc/difuzio/secrets/$k.key
    done
    chown root:root /etc/difuzio/secrets/*.key
    chmod 0600 /etc/difuzio/secrets/*.key

Les fichiers restent `root:root` : c'est systemd (root) qui les lit pour
`LoadCredential=` et en dépose une copie dans un tmpfs privé à chaque service.
Sur les systemd récents (v256+), cette copie est en `0440 root:<groupe du
service>` : lisible via le bit groupe, et volontairement pas détenue par le
compte de service, qui ne peut donc ni la modifier ni en changer les droits.

Contraintes vérifiées au chargement, chacune fatale : base64 standard décodant au
moins 32 octets ; fichier inaccessible au groupe et aux autres (0640 est refusé,
l'échappatoire `DIFUZIO_INSECURE_KEY_PERMS=1` est réservée au développement),
avec une seule exception : sous `$CREDENTIALS_DIRECTORY`, le bit de lecture
groupe est toléré (le 0440 ci-dessus), tout autre bit restant fatal ;
version `current` présente parmi les clés déclarées ; pas de doublon de version.

### Chemins dans la configuration

Chaque unité systemd expose ses credentials dans un répertoire qui lui est propre,
dont le chemin est donné par `$CREDENTIALS_DIRECTORY`. La configuration partagée
référence donc la variable plutôt qu'un chemin figé :

    session_keyring:
      current: 1
      keys:
        - { version: 1, file: "${CREDENTIALS_DIRECTORY}/session.key" }

Une variable référencée mais non définie est une erreur de configuration explicite
qui nomme la variable manquante, jamais un chemin vide silencieux. Hors systemd,
poser la variable sur la commande (en root) :

    CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio config check --config /etc/difuzio/config.yaml

Variante sans `LoadCredential=` : écrire les chemins en clair
(`/etc/difuzio/secrets/session.key`, comme dans `config.example-external.yaml`) ;
il faut alors que le compte de service puisse lire les fichiers, donc
`chown difuzio:difuzio` sur les clés en gardant 0600, et rendre
`/etc/difuzio/secrets/` traversable par ce compte (`chgrp difuzio` + `0710`).

### Rotation

Ajouter la nouvelle version sans retirer l'ancienne, ajouter le
`LoadCredential=token.key.v2:/etc/difuzio/secrets/token.key.v2` correspondant dans
les unités, puis basculer `current` :

    token_keyring:
      current: 2
      keys:
        - { version: 1, file: "${CREDENTIALS_DIRECTORY}/token.key" }
        - { version: 2, file: "${CREDENTIALS_DIRECTORY}/token.key.v2" }

Les nouveaux tokens sont signés en v2, ceux en vol restent vérifiables en v1. Ne
retirer v1 qu'après expiration des liens concernés : une version retirée n'est pas
une panne (le lien retombe sur le formulaire web), mais l'abonné perd le lien
direct.

### Sauvegarde

Sauvegarder les trois fichiers à part, chiffrés, séparément des dumps SQL. Perdre
`verp.key` casse l'attribution des bounces en vol et invalide tous les liens de
désabonnement one-click déjà distribués, y compris ceux des messages archivés chez
les abonnés : une restauration de base sans les clés d'origine est incomplète.

## 3. Configuration

Copier `config.example.yaml` vers `/etc/difuzio/config.yaml` et l'adapter (voir
[Configuration](/difuzio/admin/configuration) pour le détail de chaque clé). Valider la
configuration avant tout démarrage :

    CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio config check --config /etc/difuzio/config.yaml

Cette commande charge le fichier, applique les surcharges d'environnement, charge
les keyrings et vérifie les invariants. Un invariant cassé fait échouer la
commande (et le démarrage). En particulier `worker.reclaim_seconds` doit être
supérieur ou égal à deux fois `relay.smtp_timeout`, et chaque domaine doit
déclarer un `base_url`.

## 4. systemd

Copier les unités de `deploy/systemd/` dans `/etc/systemd/system/` (rien n'est
installé automatiquement) :

    install -m 0755 difuzio /usr/local/bin/difuzio
    install -m 0644 deploy/systemd/difuzio-*.service deploy/systemd/difuzio.target /etc/systemd/system/
    systemctl daemon-reload

Le durcissement est déjà inline dans chaque unité : `difuzio.hardening.conf` n'est
qu'une référence, à déployer seulement si on veut un drop-in (un répertoire
`difuzio-<unite>.service.d/` par unité). Les trois unités de service déclarent les
trois clés en `LoadCredential=`, plus la ligne commentée du `credential.key`
optionnel (systemd refuse de démarrer si un fichier déclaré manque, d'où le
commentaire par défaut).

Les quatre unités partagent `RuntimeDirectory=difuzio` (`/run/difuzio`, où vit le
socket LMTP) et déclarent donc `RuntimeDirectoryPreserve=yes`. Sans cette ligne,
la première unité qui s'arrête efface le répertoire pendant que les autres
démarrent, et celles-ci échouent en construisant leur espace de noms :
`Failed to set up mount namespacing: /run/difuzio: No such file or directory`
(état `226/NAMESPACE`). Si une installation antérieure porte des unités sans
cette directive, l'ajouter par un drop-in suffit.

`difuzio-migrate.service`, lui, ne lit pas la configuration YAML : il reçoit le
DSN du compte DDL (section 1) par `EnvironmentFile=/etc/difuzio/migrate.env`. Ce
n'est pas un doublon du `db.dsn` du YAML : le YAML porte le compte applicatif
sans droits DDL, `migrate.env` porte le compte privilégié, lisible par la seule
unité de migration et jamais passé sur argv (donc invisible dans
`/proc/<pid>/cmdline`). Créer ce fichier avant le premier démarrage :

    umask 027
    cat > /etc/difuzio/migrate.env <<'EOF'
    DIFUZIO_MIGRATE_DSN=difuzio_ddl:MOT_DE_PASSE@tcp(127.0.0.1:3306)/difuzio?parseTime=true&loc=UTC
    EOF
    chown root:difuzio /etc/difuzio/migrate.env
    chmod 0640 /etc/difuzio/migrate.env

Sans ce fichier, l'unité échoue avant même de lancer le binaire ("Failed to load
environment files: No such file or directory", résultat `resources`). Avec le
backend SQLite (voir [Base de données](/difuzio/admin/base-de-donnees)), il n'y a
pas de comptes séparés : mettre le chemin du fichier de base dans `migrate.env`
(`DIFUZIO_MIGRATE_DSN=/var/lib/difuzio/difuzio.db`), ou remplacer le fichier
d'environnement par un drop-in (`systemctl edit difuzio-migrate.service`) qui lit
la configuration :

    [Service]
    EnvironmentFile=
    ExecStart=
    ExecStart=/usr/local/bin/difuzio migrate up --config /etc/difuzio/config.yaml

Puis :

    systemctl enable --now difuzio-migrate.service
    systemctl enable difuzio-web.service difuzio-lmtp.service
    systemctl enable difuzio-worker@1.service difuzio-worker@2.service
    systemctl enable --now difuzio.target

En profil boîte externe, ne PAS activer `difuzio-lmtp.service` : rien ne lui
remettrait de courrier, et l'unité resterait à écouter une socket que personne
n'alimente. Deux services suffisent, `difuzio-web` et un seul `difuzio-worker@1`
(le poller IMAP est un singleton, voir
[Comptes de messagerie externes](/difuzio/admin/boite-externe)) :

    systemctl enable difuzio-web.service difuzio-worker@1.service
    systemctl enable --now difuzio.target

`difuzio-migrate.service` est un oneshot que les trois services tirent par
`Requires=` et `After=` : **les migrations en attente sont appliquées
automatiquement avant chaque démarrage**, sans commande à retenir. Après un
passage réussi, l'unité repasse `inactive (dead)` : c'est l'état normal, et c'est
ce qui permet qu'elle soit rejouée au démarrage suivant. Quand il n'y a rien à
appliquer, `migrate up` ne fait rien (une connexion et une lecture de version).

Deux directives de cette unité ne doivent pas être touchées :

- pas de `RemainAfterExit=yes` : l'unité resterait `active (exited)` et ne serait
  plus jamais rejouée, ce qui remettrait la migration à la charge de
  l'administrateur ;
- `TimeoutStartSec=infinity` : le délai par défaut de systemd (90 s) tuerait une
  migration longue en plein `ALTER TABLE` et laisserait le schéma `dirty`, état
  qui bloque tous les services jusqu'à un `difuzio migrate force`.

Conséquence à connaître : sur une grosse base, une migration lourde retarde le
démarrage de la pile d'autant. C'est voulu (un service arrêté vaut mieux qu'un
service qui interroge des colonnes absentes), mais cela veut dire qu'un
`systemctl restart` juste après une mise à jour de binaire n'est pas toujours
instantané.

Les services sont rattachés à la cible `difuzio.target` (`WantedBy=` +
`PartOf=`) : activer un service l'accroche à la cible, et démarrer la cible
démarre tous les services activés. Toute la pile se pilote ensuite en une seule
commande :

    systemctl restart difuzio.target   # redémarre web + lmtp + tous les workers
    systemctl stop difuzio.target      # arrête toute la pile

`difuzio-migrate.service` n'est volontairement pas rattaché à la cible : chaque
service le tire déjà par `Requires=`, ce qui suffit à le rejouer au démarrage.
L'y rattacher par `PartOf=` ne changerait rien d'utile et ferait dépendre l'ordre
des migrations de la cible plutôt que des services qui en ont besoin.

On multiplie les unités `difuzio-worker@N` pour augmenter le débit (instances
nommées avec un chiffre : le template dérive le port de métriques de l'instance,
`/metrics` sur 9091 pour `@1`, 9092 pour `@2`, cf
[Exploitation](/difuzio/admin/exploitation)). Chaque
`worker` exécute le pipeline de traitement des posts, l'envoi sortant, la
livraison des webhooks et les tâches périodiques (kill-switch, digests, événements
de fin d'envoi, purges) ; les tâches périodiques sont des singletons protégés par
verrou applicatif, sûres à lancer en plusieurs exemplaires (voir
[Exploitation](/difuzio/admin/exploitation)). Un seul `serve-lmtp` par hôte MTA suffit ; seul
`serve-web` est exposé derrière le reverse proxy (vhosts Apache et Nginx complets :
[Proxy web frontal](/difuzio/admin/reverse-proxy)). Les services daemon s'arrêtent
proprement sur SIGTERM (arrêt gracieux).

## 5. Premier super_admin

Créer le premier super administrateur, de préférence AVANT d'exposer le vhost :

    CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio bootstrap-admin --config /etc/difuzio/config.yaml \
        --email vous@example.org --password-stdin

Le mot de passe est lu sur l'entrée standard avec `--password-stdin` (préférable à
`--password` qui apparaît dans l'historique shell). Si un super_admin existe déjà,
la commande refuse (utiliser `--force` pour en ajouter un autre).

La page web `/setup` est un repli atomique : tant qu'aucun super_admin n'existe,
elle permet d'en créer un ; dès qu'il y en a un, elle renvoie 404.

## 6. Domaine et intégration Postfix

> Profil MTA local uniquement. En profil boîte externe, il n'y a pas de Postfix
> ni de LMTP : provisionner le domaine avec `difuzio add-domain`, sauter le reste
> de cette section et toute la section 7, et suivre
> [Comptes de messagerie externes](/difuzio/admin/boite-externe).

Provisionner un domaine puis imprimer les lignes Postfix :

    CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio add-domain --config /etc/difuzio/config.yaml \
        --name lists.example.org --base-url https://lists.example.org
    difuzio mta-config --domain lists.example.org

Reporter les lignes imprimées dans la configuration Postfix. Le domaine entier
(posts, `bounce+*@`, `fbl@`) est routé vers la socket LMTP de difuzio. Voir
[Domaines](/difuzio/admin/domaines) pour le détail.

## 7. Prérequis Postfix (sécurité et délivrabilité)

Ces points conditionnent la sécurité et la délivrabilité ; ils ne sont pas
assurés par difuzio seul :

1. Anti-forge `Authentication-Results` : OpenDKIM/OpenDMARC en frontal DOIVENT
   supprimer tout `Authentication-Results` préexistant portant notre authserv-id à
   l'entrée.
2. Antivirus : un scan amavis/clamav AVANT la remise LMTP est recommandé (difuzio
   n'assure qu'un filet par extension et type MIME).
3. ARC : OpenARC en frontal pour sceller la chaîne (recommandé pour les listes à
   volume et les abonnés qui forwardent).
4. Routage FBL : `fbl@<domaine>` routé vers difuzio et enregistré auprès des
   programmes FBL (Yahoo CFL, Microsoft JMRP).
5. Rétention des logs : `journald` (`MaxRetentionSec`/`SystemMaxUse`) ou
   `logrotate` alignés sur la rétention de 90 jours des logs applicatifs (PII).
6. Paramètres DSN : le relais submission doit transmettre les paramètres DSN
   RFC 3461 posés par difuzio.
7. Pools d'envoi : pour isoler la réputation, mapper chaque pool nommé vers une IP
   source dédiée (voir [Délivrabilité](/difuzio/admin/delivrabilite)).

Le `POST /p/unsubscribe-oneclick` (sans authentification, sans CSRF) doit figurer
dans la liste d'exceptions du reverse proxy / WAF pour ne pas être bloqué : c'est
le point d'entrée du désabonnement en un clic (RFC 8058).

## 8. Vérifications post-installation

    CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio doctor --config /etc/difuzio/config.yaml
    difuzio check-dns --domain lists.example.org --dkim-selector default
    curl -fsS http://127.0.0.1:8080/readyz

`doctor` vérifie la joignabilité de la base et l'état du schéma. `check-dns`
vérifie SPF, le sélecteur DKIM et `_dmarc` (>= p=none) et remonte un drapeau
"bulk-sender ready". `/readyz` renvoie 200 si la base est joignable et le schéma à
jour, 503 sinon.

Compléter le monitoring par la profondeur de file Postfix (`mailq`) : un `250` du
relais signifie "remis au relais", pas "livré au destinataire".

## 9. Sauvegarde

`mariadb-dump --single-transaction` nocturne (compressé) plus binlogs pour le
point-in-time recovery. Sous SQLite, `sqlite3 <fichier> ".backup <cible>"` (jamais
un `cp` à chaud, le journal WAL est actif) : voir
[Base de données](/difuzio/admin/base-de-donnees). Sauvegarder les keyrings à
part, chiffrés : sans eux, une restauration de base ne permet pas de revérifier
les tokens en circulation.

Sauvegarder également `/var/lib/difuzio/messages` (`storage.blob_dir`) : c'est là
que vit le brut des messages, la base n'en garde que le chemin. Détails et ordre
recommandé dans [Exploitation](/difuzio/admin/exploitation).

## 10. Mise à jour

**L'ordre compte : migrations d'abord, nouveau code ensuite.** Le nouveau code
interroge les colonnes ajoutées par ses migrations ; sur l'ancien schéma, il
échouerait sur chaque requête concernée. L'inverse est sans danger : le binaire
en place ignore une colonne qui vient d'apparaître.

Sous systemd, cet ordre est garanti sans rien faire : copier le nouveau binaire
sur le disque ne le démarre pas (l'ancien processus continue de tourner sur son
image en mémoire), et au redémarrage `difuzio-migrate.service` passe avant les
services.

difuzio le vérifie lui-même. Un service démarré sur un schéma en retard **refuse
de démarrer** en nommant les deux versions et la commande à lancer :

    worker: the database schema is at version 32 but this binary expects 33
    (1 migration(s) pending); run "difuzio migrate up" with the DDL account
    (systemctl start difuzio-migrate.service) before starting the new binary

`/readyz` répond alors 503 avec le même détail, ce qui évite qu'un répartiteur de
charge envoie du trafic vers un processus incapable de répondre. Un schéma plus
récent que le binaire (retour arrière sur la version) n'est qu'un avertissement
dans le journal : les migrations sont additives.

Sous systemd, il n'y a **aucune commande de migration à lancer** : chaque service
tire `difuzio-migrate.service` par `Requires=`, et cette unité applique les
migrations en attente avant que la pile ne démarre (section 4). Le compte
applicatif n'a toujours pas les droits DDL : c'est l'unité de migration qui porte
le compte privilégié, via `migrate.env`.

Déroulé complet :

    # 1. Sauvegarder AVANT de redémarrer : le redémarrage applique les migrations
    mysqldump ... > difuzio-$(date +%F).sql
    tar czf messages-$(date +%F).tgz /var/lib/difuzio/messages

    # 2. Construire (make bin injecte la version et la date de build)
    make bin
    make webui-build

    # 3. Binaire, puis redémarrage : les migrations passent au démarrage
    install -m 0755 difuzio /usr/local/bin/difuzio
    systemctl restart difuzio.target

    # 4. Console (elle se livre séparément du binaire)
    rsync -a --delete webui/dist/ root@serveur:/var/www/difuzio-console/

L'étape 1 n'est pas décorative : c'est la seule fenêtre où la base est encore
dans son état d'avant migration. Une fois la pile redémarrée, le schéma a changé.

Pour garder la main sur le moment de la migration (grosse base, fenêtre de
maintenance négociée), l'appliquer d'abord, services en marche :

    systemctl start difuzio-migrate.service
    difuzio migrate status --dsn "<DSN>"      # version attendue, clean

Le redémarrage qui suit n'aura alors plus rien à appliquer. L'ordre reste le
même : migrations d'abord, binaire ensuite.

Hors systemd (conteneur, service lancé à la main), rien ne joue les migrations :
c'est là que le refus de démarrer décrit plus haut sert de filet, et il faut
lancer `difuzio migrate up` soi-même.

Vérifier ensuite avec `difuzio doctor --config /etc/difuzio/config.yaml`, la page
"À propos" de la console (les deux révisions doivent concorder, voir
[Exploitation](/difuzio/admin/exploitation)) et un message de test.

Retour arrière : revenir au binaire précédent AVANT de défaire une migration
(`difuzio migrate down`), sinon le nouveau code tourne sur un schéma qu'il ne
connaît plus.
