1. Pourquoi je n’ai pas construit ce blog sur Astro
Mon premier réflexe pour ce blog a été Astro. Il ne livre aucun JavaScript par défaut et rend cette propriété difficile à casser par erreur - il faut activement faire entrer une île dans l’hydratation, donc un blog plein d’articles statiques reste statique presque par construction. Le plan initial était Astro pour le front, avec Payload CMS tournant comme un service conteneurisé séparé, communiquant via une API REST. Une séparation propre, deux choses faisant chacune un seul travail.
Le problème est apparu dès que j’ai vraiment regardé ce qui rend Payload intéressant. Ses véritables avantages - la Local API, qui permet aux Server Components d’interroger la base directement, sans passer par HTTP ; le panneau d’administration qui se monte à l’intérieur de votre propre app plutôt que de vivre sur un sous-domaine séparé ; l’édition en contexte en direct - tout cela n’existe que lorsque Payload tourne dans le même processus Node que le front. En le faisant tourner comme service séparé, on obtient un CMS headless tout à fait correct parlant REST, ce qui est bien, mais ce n’est pas ce pour quoi on paie la complexité de Payload.
J’ai donc renoncé à la propreté deux-services-un-travail-chacun et je suis passé à Next.js App Router avec Payload embarqué dans le même processus - un seul codebase, un seul déploiement, un seul runtime à sécuriser au lieu de deux. Je n’ai pas totalement abandonné le réflexe zéro-JS ; j’ai simplement accepté qu’il demande désormais de la discipline plutôt que d’être acquis gratuitement. Des Server Components par défaut, et garder `use client` hors du corps des articles en particulier, couvre l’essentiel du chemin. C’est une décision que je dois reprendre à chaque pull request plutôt qu’une décision prise une fois dans un choix de framework - un vrai coût, mais que j’ai jugé valoir la peine pour ce que Payload m’apporte une fois embarqué.
2. Abandonner le backend FastAPI dont je n’ai jamais eu besoin
Le plan initial prévoyait aussi un service FastAPI séparé en Python, gérant les pourboires, l’inscription à la newsletter, le formulaire de contact et les compteurs de vues/engagement des articles - le genre de petites fonctionnalités avec état qui ne semblent pas évidemment appartenir à un CMS. J’ai commencé à l’ébaucher avant que la vraie question ne me frappe : qu’est-ce que FastAPI fait ici que Payload ne fait pas déjà ?
Payload est un framework backend complet, pas juste un modèle de contenu. Des collections, des règles de contrôle d’accès, des hooks qui s’exécutent à la création/mise à jour/suppression, des endpoints REST et GraphQL personnalisés, le tout contre la même base Postgres où vivent déjà les articles. Une inscription à la newsletter est une collection avec un champ email et un hook beforeChange qui appelle un fournisseur d’email. Une confirmation de pourboire est une collection avec un endpoint de création vérifié par webhook. Rien de tout cela n’avait besoin d’un second langage et d’un second runtime installés à côté de Next.js sans aucun bénéfice d’intégration - cela avait besoin de fonctionnalités que je pouvais construire directement dans le framework que je faisais déjà tourner.
J’ai supprimé FastAPI entièrement et reconstruit ces quatre fonctionnalités sous forme de collections Payload et d’endpoints personnalisés dans le même processus Next.js. J’ai néanmoins laissé la porte ouverte à un vrai service Python plus tard, spécifiquement pour des fonctionnalités IA - recherche sémantique sur les articles, recommandations de contenu - parce que c’est un cas où l’écosystème Python est un avantage réel, pas une habitude. Je ne le construis pas maintenant, car aucune fonctionnalité concrète ne l’exige encore, et je préfère ajouter cette complexité quand un besoin réel l’impose plutôt que de la porter par anticipation dès le premier jour.
3. Incident un : Postgres natif et l’ETIMEDOUT qui m’a servi de leçon
Pour la base de données, j’ai d’abord essayé de faire tourner Postgres nativement sur l’hôte VPS via systemd plutôt qu’en conteneur. Le raisonnement semblait solide sur le moment : ce VPS n’héberge que ce seul projet, donc aucune raison multi-tenant de conteneuriser Postgres, et je pouvais connecter le conteneur de l’app au Postgres de l’hôte via le routage host-gateway de Docker (host.docker.internal). Un conteneur de moins à gérer, pensais-je.
Ça a cassé dès le lendemain. Le conteneur de l’app s’est mis à renvoyer des ETIMEDOUT en essayant de joindre Postgres via host-gateway. Rien n’avait changé dans ma config - c’était le réseau spécifique de ce VPS qui ne résolvait pas host.docker.internal de manière fiable, exactement le genre de particularité propre à l’hôte qu’on ne découvre qu’une fois déjà exposé à du trafic réel.
Je suis revenu à faire tourner Postgres comme son propre service dans la même stack Docker Compose que l’app et Caddy, joignable via la simple résolution DNS par nom de service de Compose - postgres:5432, sans indirection host-gateway, sans règles de pare-feu épinglées à un sous-réseau à maintenir dans la durée. C’est une configuration moins astucieuse que de raisonner sur le réseau de l’hôte, et c’est exactement pour cela qu’elle fonctionne : moins de pièces mobiles dépendant des particularités de cet hôte précis. La leçon que j’en ai tirée ne concernait pas Postgres en particulier, mais le fait que plus simple parce qu’il n’y a qu’un seul projet sur cette machine n’est pas la même affirmation que plus simple parce qu’il y a moins de choses qui peuvent mal tourner - et j’avais optimisé pour la mauvaise des deux.
4. Incident deux : un faux certificat TLS et un fantôme k3s
Le second incident était plus étrange. Les visiteurs recevaient un certificat auto-signé nommé TRAEFIK DEFAULT CERT au lieu du vrai certificat Let’s Encrypt de Caddy - un avertissement de navigateur sur un site censé avoir du HTTPS automatique. J’ai fait tous les contrôles Docker auxquels je pouvais penser : les conteneurs caddy, app et postgres tous sains, les logs de Caddy montrant qu’il avait bien émis et servait le bon certificat, le Caddyfile correct. Tout était propre au niveau Docker, ce qui était exactement le problème - je regardais entièrement dans la mauvaise couche.
La cause réelle était en amont de Docker : une installation résiduelle de k3s (Kubernetes léger) sur le VPS, sans rapport avec ce projet, laissée par quelque chose qui avait tourné sur cette machine avant que je ne la récupère. k3s embarque son propre contrôleur d’ingress Traefik, et ses règles iptables redirigeaient (DNAT) les ports 80 et 443 vers une IP de pod k3s dans la chaîne PREROUTING du noyau - en amont de, et invisible pour, la publication de ports propre à Docker. Docker pensait posséder les ports 80/443. Le noyau n’était pas d’accord, et c’est le noyau qui gagne.
Je l’ai trouvé en exécutant `iptables -t nat -L PREROUTING -n -v` et en repérant une règle CNI-HOSTPORT-DNAT qui n’avait rien à faire là. La correction a été le script officiel `k3s-uninstall.sh`, et le certificat s’est remis à se résoudre correctement en quelques minutes. La leçon généralisable, que j’applique désormais sur tout VPS à l’historique inconnu : un symptôme de couche réseau qui ressemble à un bug applicatif ou Compose mérite d’être vérifié tôt au niveau iptables/noyau, pas en dernier recours - tous les diagnostics Docker peuvent passer proprement pendant que quelque chose en amont de Docker réécrit silencieusement le chemin des paquets.
5. Stockage média : du disque local à Cloudflare R2
Le stockage local par défaut de Payload écrit les médias uploadés sur le disque à l’intérieur du conteneur. Cela fonctionne exactement tant qu’on ne redéploie jamais - chaque déploiement reconstruit et remplace le conteneur, donc tout ce qui est écrit sur le disque local disparaît sauf s’il se trouve sur un volume monté, et même avec un volume, le disque du VPS grossit sans limite naturelle à mesure que les médias s’accumulent.
Je suis passé à Cloudflare R2, compatible S3, ce qui fait que l’adaptateur officiel @payloadcms/storage-s3 fonctionne dessus sans modification - juste un endpoint et une région différents dans la config, rien de spécifique à R2 à écrire. La raison principale pour laquelle j’ai choisi R2 plutôt qu’un simple S3 est l’egress : R2 ne facture pas la bande passante sortante, et pour un blog avec une audience internationale majoritairement hors des États-Unis, des pages d’articles riches en images, et aucun moyen de prévoir les pics de trafic, ce n’est pas un détail comptable mineur, c’est la différence entre une facture prévisible et une facture qui explose selon le succès imprévu d’un article.
6. Les paiements sans Stripe : le problème camerounais
Stripe n’opère pas au Cameroun. Ce n’est pas une limitation d’API qu’on contourne avec de l’ingéniosité, c’est une décision commerciale de leur côté qu’aucune intégration astucieuse ne corrige. Les comptes PayPal camerounais standards aggravent le problème : ils sont send-only, ce qui signifie que je pouvais payer quelqu’un via PayPal mais pas recevoir ni retirer un pourboire via un compte normal. Deux des rails de paiement les plus évidents m’étaient tout simplement inaccessibles en tant que destinataire de l’argent, un problème dont la plupart des tutoriels sur ajouter un bouton de pourboire ne vous préparent pas.
Plutôt que de bloquer le lancement sur une résolution complète de ce problème, j’ai opté pour une approche par paliers, livrable de façon incrémentale. Le palier 0, au lancement, est simplement un lien externe vers la plateforme la plus pertinente pour un lecteur donné. Le palier 1, que j’ai effectivement construit, ce sont les boutons PayPal rendus côté client : la création de la commande se fait dans le navigateur via le SDK PayPal, et le seul rôle de mon backend est de vérifier la signature du webhook avant d’enregistrer un pourboire confirmé - délibérément aucun prix fixe imposé côté serveur au-delà de ce que le webhook lui-même confirme, précisément pour éviter qu’un client ne puisse manipuler un montant que le serveur n’avait jamais affirmé indépendamment au départ.
Le palier 2, pas encore construit, est NotchPay - une passerelle de paiement basée au Cameroun qui supporte le Mobile Money, ce qui compte parce que la majorité de l’audience locale et francophone africaine pour laquelle j’écris réellement n’a pas de carte bancaire à saisir dans un formulaire Stripe ou PayPal. Je séquence volontairement dans cet ordre : livrer ce qui fonctionne aujourd’hui, et ajouter le rail qui sert mon audience réelle une fois que le reste du site a fait ses preuves, plutôt que de bloquer le lancement sur l’intégration de paiement la plus difficile en premier.
7. Localisation : un seul arbre de contenu, pas deux
J’écris en anglais et en français, et l’esquisse initiale prévoyait deux arbres de contenu séparés - une collection /en/ et une collection /fr/, chacune gérable indépendamment, ce qui semble flexible jusqu’au moment où il faut garder deux jeux de slugs, deux jeux de métadonnées SEO et deux états de publication synchronisés à la main pour chaque article.
La localisation au niveau des champs, intégrée nativement à Payload, fait mieux : un seul document par article, avec des champs spécifiques (titre, description, le tableau de sections lui-même) marqués comme localisés, Payload stockant et servant les valeurs par locale à partir du même enregistrement sous-jacent. Il y a un seul slug, un seul état publié/brouillon, un seul jeu de relations et de règles de contrôle d’accès - seul le contenu réellement traduisible se dédouble par locale, pas le document entier.
Le gain pratique se voit aussi dans le panneau d’administration : éditer un article revient à basculer un sélecteur de locale, pas à naviguer vers une collection entièrement séparée en espérant que les deux restent conceptuellement synchronisées. C’est moins de code qu’une couche de traduction rapportée après coup, et il est structurellement impossible que les versions anglaise et française d’un article dérivent vers deux articles différents portant le même titre, puisqu’il s’agit du même document.
8. Ce que je referais, et ce que je ferais différemment
L’architecture sur laquelle j’ai fini - Next.js App Router avec Payload embarqué dans le même processus, Postgres et Caddy dans la même stack Compose, R2 pour les médias, GitHub Actions qui construit et pousse vers GHCR puis se connecte en SSH pour redéployer à chaque push sur main - est ennuyeuse au bon sens du terme. Chaque brique dispose d’une large communauté et d’une documentation claire, et ennuyeux est exactement ce qu’on veut pour une infrastructure maintenue en solo à côté du vrai travail d’écriture.
Ce que je referais sans hésiter : embarquer Payload dans Next.js plutôt que de le faire tourner séparément, abandonner FastAPI avant d’avoir écrit beaucoup de code contre lui, déplacer les médias vers R2 avant que le disque local ne devienne un vrai problème plutôt qu’après, et utiliser la localisation au niveau des champs de Payload dès le départ plutôt que de la greffer après coup. Ce sont toutes des décisions que j’ai bien prises du premier coup parce que je me suis demandé ce que j’obtiens réellement de cette brique supplémentaire avant de l’ajouter, pas parce que j’avais prédit l’avenir.
Ce que je ferais différemment : j’exécuterais `iptables -t nat -L PREROUTING` dès le premier jour du provisionnement de tout nouveau VPS, avant de déployer quoi que ce soit, précisément pour repérer d’emblée exactement le genre de résidu k3s qui m’a coûté un après-midi à traquer un faux bug TLS à travers toutes les couches sauf la bonne. Et je partirais par défaut sur Postgres dans Compose dès le début plutôt que de me convaincre par le raisonnement d’opter pour Postgres natif plus host-gateway comme option plus simple - le réflexe de réduire le nombre de conteneurs est généralement juste, mais pas quand l’alternative dépend du fait que le réseau de cet hôte précis se comporte comme on l’a supposé.
# Tous les checks Docker passent, et le certificat reste incorrect.# Verifier en amont de Docker : quelque chose d'autre fait-il du DNAT sur 80/443 ?sudo iptables -t nat -L PREROUTING -n -v | grep -i dnat# Trouve :# Chain PREROUTING (policy ACCEPT)# pkts bytes target prot opt in out source destination# 3 180 CNI-HOSTPORT-DNAT all -- * * 0.0.0.0/0 0.0.0.0/0# Cette regle appartient a une installation k3s residuelle, pas a ce projet.curl -sfL https://get.k3s.io | sh -s - --help >/dev/null 2>&1 || truesudo /usr/local/bin/k3s-uninstall.sh# Redemarre la stack, verifie que le vrai certificat revient.docker compose down && docker compose up -dcurl -vI https://armeltechfun.com 2>&1 | grep -i "issuer\|subject"