Aller au contenu
gaetancottrez.dev

Un serveur SFTP sans démon SSH : pourquoi et comment j'ai branché les dépôts de fichiers sur un bucket

Publié: at 08:00 | (8 min de lecture)
Un serveur SFTP sans démon SSH : pourquoi et comment j'ai branché les dépôts de fichiers sur un bucket

Table des matières

Ouvrir table des matières

L’EDI n’est pas mort, et il veut un répertoire

On est en 2026, tout le monde parle d’API REST, de webhooks, d’événements. Et puis un jour, un client vous dit tranquillement : « nous, on s’intègre en EDI. Vous nous donnez un SFTP, on dépose nos fichiers dans un répertoire, on récupère les retours dans un autre. »

Ne souriez pas. L’EDI est partout — ERP historiques, éditeurs métier, grands comptes — et il est là pour longtemps. Un fichier déposé dans un répertoire, c’est l’API des systèmes qui existaient avant les API. La vraie question n’est pas de savoir si vous devez le supporter (si vos clients le demandent, vous le devez), mais comment le supporter sans saboter votre architecture.

Et là, il y a un piège dans lequel je vois plonger beaucoup d’équipes Node : « il nous faut un serveur SFTP ? Très bien, il y a une lib pour ça, embarquons un serveur SSH dans l’application. » Techniquement, ça marche. Architecturalement, c’est une petite catastrophe en devenir. Voici pourquoi j’ai fait l’inverse : un endpoint SFTP par client, sans qu’une seule ligne de mon backend ne parle jamais SSH.

Pourquoi je ne voulais pas d’un démon SSH dans mon backend

Posons les griefs contre le serveur SSH embarqué, parce qu’ils sont sérieux :

Bref, je ne voulais pas héberger du SSH. Je voulais juste que les fichiers de mes clients arrivent. Ce sont deux besoins différents — et le premier n’est pas le mien.

La décision : une passerelle devant, un bucket derrière

La solution tient en une phrase : une passerelle SFTP spécialisée, configurée pour écrire dans un stockage objet. Il existe d’excellents outils open source pour ça (SFTPGo, pour ne pas le nommer) dont c’est précisément le métier : parler SSH/SFTP d’un côté, et ranger les octets reçus dans un backend de stockage de l’autre — un bucket S3-compatible, en ce qui me concerne.

Le partage des rôles devient limpide :

Et comment le consomme-t-elle ? C’est le deuxième étage de la fusée, et mon pattern préféré de ce chantier : le bucket-as-queue. Un poller périodique liste le préfixe d’entrée, détecte les nouveautés et les injecte dans le pipeline métier. Le bucket joue le rôle d’une file d’attente durable : les dépôts s’y accumulent même si l’application est en cours de déploiement, rien ne se perd, et le traitement rattrape à son rythme. Ce patron tournait d’ailleurs déjà ailleurs sur la plateforme pour un autre canal d’entrée — la nouveauté ici, c’est la porte, pas la mécanique.

Passerelle SFTP, bucket, et le pipeline métier unique

Au passage, vous noterez que le préfixe fait d’une pierre deux coups : c’est aussi lui qui porte la tenance. Le client propriétaire d’un fichier, c’est le premier segment de son chemin de stockage — l’endroit où il a atterri, que nous avons provisionné — et jamais ce que le fichier raconte. J’ai consacré un article entier à cette règle, je n’y reviens pas, mais elle s’applique ici au premier chef : résolution nulle ou ambiguë, refus et quarantaine.

Le ledger est l’autorité, les fichiers sont une projection

Un piège classique des intégrations à base de répertoires : laisser l’arborescence de fichiers être l’état du système. « Si le fichier est dans done/, c’est traité ; s’il est dans error/, c’est en erreur. » Ça semble simple, jusqu’au premier crash entre deux opérations — et vous voilà avec un fichier traité mais pas déplacé, ou déplacé mais pas traité, et aucun moyen de trancher.

Ma règle : la vérité vit en base, dans un ledger ; l’arborescence n’est qu’une projection pour les humains et les clients. Chaque dépôt détecté est enregistré dans un registre avec une clé de déduplication dérivée de son emplacement — c’est elle qui garantit l’exactly-once : si le poller revoit le même fichier (redémarrage, chevauchement, redélivrance), la clé existe déjà, on ne retraite pas. Ensuite seulement, le fichier est déplacé vers son répertoire de destin, en mark-then-move : on marque en base, puis on déplace. Un crash entre les deux ? Au tick suivant, le marquage dit la vérité et le déplacement se rejoue — l’opération est idempotente, le pire scénario est un fichier en retard d’un rangement, jamais un fichier traité deux fois.

// Le squelette du poller — la base décide, le fichier suit.
for (const object of await storage.list(`inbound/${clientId}/`)) {
  const dedupeKey = `sftp:${clientId}:${object.path}`;
  const claim = await ledger.claim(dedupeKey); // INSERT … ON CONFLICT DO NOTHING
  if (!claim.isNew) continue; // déjà vu : redélivrance, on ignore

  const outcome = await processDeposit(clientId, object); // le MÊME pipeline que l'API
  await ledger.record(dedupeKey, outcome);
  await storage.move(object.path, projectionPathFor(outcome)); // la projection, en dernier
}

Vous reconnaissez la philosophie des articles précédents : l’état d’autorité est un enregistrement atomique en base, et tout le reste — fichiers, écrans, métriques — n’en est que le reflet. La base de données la plus ennuyeuse reste le meilleur arbitre du monde.

Le même chemin métier que l’API — c’est ça, la vraie victoire

Le plus gros du travail n’a d’ailleurs pas été le SFTP du tout. Ç’a été un refactoring : extraire le pipeline de traitement de la couche HTTP, où il vivait confortablement dans les handlers de routes, pour en faire un use-case injectable et transport-agnostique. L’identifiant du client y est fourni par l’appelant — qui l’a résolu depuis sa propre source de vérité : le jeton pour HTTP, le préfixe pour SFTP — et les issues sont typées, sans texte localisé ni code de statut HTTP dans la couche applicative.

Résultat : un document déposé par SFTP traverse exactement le même chemin métier qu’un document poussé par l’API. Mêmes validations, mêmes règles, mêmes registres, mêmes obligations. Le SFTP n’est pas un « mode dégradé » avec sa propre logique qui divergera dans six mois — c’est un adaptateur de plus devant le même hexagone. Le jour où un client demandera un troisième canal (un partage réseau ? une boîte mail ? qui sait, avec l’EDI), ce sera un adaptateur de plus, pas une réécriture.

Trois raffinements de résilience pour finir le tour du propriétaire :

Épilogue

Ce que cette histoire de SFTP m’a confirmé, c’est qu’un besoin « legacy » ne mérite pas une architecture au rabais. Le réflexe « c’est de l’EDI, on va bricoler un truc à côté » produit des chemins parallèles qui divergent, des états ambigus et des démons qu’on n’aurait jamais dû héberger. Le bon mouvement est inverse : traiter le transport comme un détail — passerelle spécialisée, stockage objet, poller idempotent — et faire converger tous les canaux vers l’unique chemin métier.

Pour prolonger : l’architecture hexagonale qui rend ce genre d’extraction naturelle, la résolution de tenant qui est l’autre moitié de cette porte d’entrée, et le quota partagé qui gouverne ce qui ressort de l’autre côté.

Et chez vous, l’EDI arrive comment ? Démon embarqué, passerelle, ou pire (on a tous vu le cron qui scanne un partage Samba) ? Racontez-moi vos portes d’entrée en commentaire — je me ferai une joie de vous répondre.

Vous pourriez aussi aimer

Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard

Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard

Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données

Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données

Article précédent
L'IA code plus vite que vous. Mais qui vérifie ce qu'elle raconte ?
Article suivant
À qui est ce document ? Pourquoi on ne résout jamais un tenant depuis le contenu