← All posts

Nous avons déplacé nos images vers un CDN et le serveur d'origine a continué à les servir

Authagonal·July 30, 2026

Notre documentation est chargée de captures d'écran. Chaque fonctionnalité du portail, capturée en clair et en sombre, en deux largeurs, dans dix langues, ce qui finit par dépasser les cent mégaoctets d'images. Tout cela se trouvait dans le bundle statique de l'application, servi depuis le serveur d'origine, ce qui est le mauvais endroit pour cent mégaoctets d'images qui ne changent jamais. Nous les avons donc déplacées vers Cloudflare R2 et placé un CDN devant, un par environnement : cdn.authagonal.io pour la production et un hôte CDN correspondant pour notre site de dev.

L'application choisit le bon hôte CDN à l'exécution en regardant le nom d'hôte depuis lequel elle est servie. Si la page est sur authagonal.io, les images viennent de cdn.authagonal.io. Simple, aucune configuration au moment du build, une seule référence d'image qui se résout correctement dans les deux environnements. Nous l'avons livré, regardé le navigateur tirer chaque image proprement depuis le CDN, puis regardé le trafic du serveur d'origine. Le serveur d'origine servait encore les images. Chacune d'elles, au premier chargement, exactement comme avant.

La page que voit un robot n'est pas la page que construit un navigateur

Pour expliquer pourquoi, je dois expliquer ce que sont réellement nos pages marketing. Ce sont une application monopage, mais une application monopage n'est qu'une coquille vide tant que le JavaScript ne s'exécute pas, et une coquille vide est mauvaise pour les moteurs de recherche et mauvaise pour le temps que met la première chose utile à apparaître. Alors, au moment du build, nous prérendons : nous démarrons le site, ouvrons chaque page dans un navigateur sans interface, la laissons se rendre entièrement et enregistrons le HTML obtenu. C'est ce HTML prérendu que nous servons en premier. Il contient déjà le contenu, le robot voit une vraie page, l'utilisateur voit immédiatement du texte et des images, puis le JavaScript prend le relais.

Voici le problème caché dans cette description. Le prérendu exécute le site sur 127.0.0.1, une adresse de bouclage sur la machine de build. Alors quand l'application demande « sur quel nom d'hôte suis-je, pour choisir un CDN », la réponse pendant le prérendu est « une adresse IP locale », qui ne correspond ni à authagonal.io ni à l'hôte de dev. La déduction de l'hôte à l'exécution, la partie astucieuse, retombe discrètement sur sa seule option restante : le chemin relatif à l'origine. Et c'est ce chemin d'origine qui se retrouve figé dans le HTML prérendu.

Ainsi chaque visiteur recevait un HTML avec des URL d'images d'origine intégrées, et le navigateur faisait l'évidence : il les récupérait, depuis l'origine, au premier rendu. Un instant plus tard, le JavaScript re-rendait la page, redéduisait le nom d'hôte, obtenant cette fois le vrai, et remplaçait les images par le CDN. Deux récupérations pour chaque image, la première frappant exactement le serveur que nous voulions épargner, et totalement invisible à moins d'observer les logs du serveur d'origine, car la page s'affichait et fonctionnait parfaitement.

La correction qui a empiré les choses pendant onze minutes

La première correction était la tentante. Si le problème est qu'une mauvaise URL se retrouve figée dans le HTML prérendu, alors n'émets aucune URL pendant le prérendu. Laisse la source de l'image vide dans l'instantané, et laisse le JavaScript renseigner la bonne URL CDN une fois qu'il s'exécute et connaît le vrai nom d'hôte. Pas de mauvaise URL, pas de récupération depuis l'origine.

C'est parti en production, et c'était faux, et nous l'avons remplacé environ onze minutes plus tard. Vider la source de l'image dans le HTML prérendu anéantit toute la raison pour laquelle nous prérendons. Le robot qui lit la page statique ne voit désormais aucune image, et le navigateur n'a rien à montrer tant que le JavaScript n'a pas tourné, ce qui est précisément le chemin lent que nous construisons le HTML prérendu pour éviter. Nous avions supprimé la double récupération en supprimant l'image de la seule version de la page qui existe avant le JavaScript. Cela échange un problème de trafic contre un problème de recherche et de premier rendu, ce qui est un plus mauvais échange.

La leçon de ces onze minutes : le HTML prérendu doit porter une référence d'image réelle et fonctionnelle. Le bug n'a jamais été qu'une URL était présente. C'était que la mauvaise URL était présente, et la vider ne fait qu'échanger un défaut contre un autre. Ce dont nous avions réellement besoin, c'était que la bonne URL puisse être connue au moment du prérendu, et elle ne l'est pas, car au moment du prérendu personne ne sait quel environnement servira la page.

Décider au dernier moment possible

La bonne URL dépend de l'hôte de la requête, et l'hôte de la requête n'est pas connu tant qu'il n'y a pas de requête. Le prérendu se produit une fois, au build, et produit un fichier qui est servi dans les deux environnements. La partie de l'URL propre à l'environnement ne peut donc pas être décidée au build. Elle doit être décidée par requête, et la seule chose de la pile qui voit chaque requête et son hôte, c'est le serveur web à l'edge.

Le prérendu n'écrit donc plus d'URL ni de vide. Il écrit un espace réservé, une chaîne sentinelle littérale, https://cdn.authagonal.io, à la place de l'hôte CDN. Le HTML prérendu qui sort du build dit https://cdn.authagonal.io/screenshots/whatever.webp, ce qui n'est ni une URL d'origine ni une source vide. C'est une URL avec un trou dedans.

nginx comble le trou à la sortie. Il fait correspondre l'hôte de la requête à la bonne base CDN, l'hôte hors production à son propre CDN de dev, authagonal.io à https://cdn.authagonal.io, tout le reste au vide de sorte que l'URL retombe en relatif à l'origine comme repli local sûr. Puis une seule directive réécrit la sentinelle vers cette valeur pendant que le HTML est diffusé vers le client :

map $host $asset_cdn_base {
    default              "";
    "~*example\.dev$"    "https://cdn.example.dev";
    "~*authagonal\.io$"  "https://cdn.authagonal.io";
}

sub_filter 'https://cdn.authagonal.io' $asset_cdn_base;

Le HTML statique quitte désormais l'origine en portant déjà de vraies URL CDN, correctes pour l'hôte qui a demandé. Le navigateur récupère directement depuis le CDN au premier rendu. Il n'y a aucune mauvaise première récupération à défaire, car il n'y a pas de mauvaise URL, et il n'y en a jamais eu de vide non plus. Le robot voit une page complète avec de vrais liens d'images. Un seul fichier sert les deux environnements, et aucun des deux n'a besoin d'un build distinct.

Il y a une petite élégance dans le choix des parties de la réponse qui sont réécrites. La réécriture est limitée au HTML, ce qui est le comportement par défaut de nginx. Cela compte parce que la même chaîne sentinelle apparaît aussi dans le bundle JavaScript, comme branche de repli du code de déduction de l'hôte. Côté client, cette branche est du code mort, puisque le navigateur a un vrai nom d'hôte et n'émet jamais la sentinelle, donc nous ne voulons surtout pas que nginx touche au bundle. Laisser la réécriture à son défaut HTML uniquement nous l'offre gratuitement. Nous avons appris, au passage, qu'écrire explicitement le défaut est pire que de l'omettre, car une déclaration redondante fait avertir nginx d'un doublon.

Pourquoi ne pas simplement réécrire le fichier une fois au démarrage

L'objection évidente est que cela fait beaucoup de travail par requête pour une valeur qui n'a que deux possibilités. Pourquoi ne pas réécrire le HTML une seule fois au démarrage du conteneur, y intégrer le bon hôte, et servir un fichier statique sans aucun filtre ?

Parce que le conteneur ne peut pas écrire en lui-même. Le site statique tourne dans nginx en tant qu'utilisateur non-root, avec un système de fichiers racine en lecture seule et chaque capacité Linux retirée, avec un unique petit répertoire de travail inscriptible pour les fichiers temporaires propres à nginx. C'est une posture de durcissement délibérée : un serveur web qui sert du trafic non fiable ne devrait pas pouvoir modifier les fichiers qu'il sert, afin qu'un attaquant qui trouve un point d'appui ne trouve rien à réécrire. Une réécriture du HTML au démarrage exigerait exactement l'accès en écriture que nous avons retiré exprès. Le système de fichiers en lecture seule n'est pas un obstacle à contourner, c'est une propriété que nous voulons, et il exclut proprement la réécriture au démarrage. La réécriture par requête ne touche aucun fichier, ce qui la rend compatible avec un serveur qui ne possède rien qu'il puisse modifier.

Nous ne voulions pas non plus intégrer l'hôte CDN au build et produire deux bundles d'images différents, un par environnement, car cela réintroduirait le build propre à l'environnement dont nous venions de nous débarrasser. Tout l'intérêt de la déduction de l'hôte était un seul artefact pour les deux environnements. La sentinelle tient cette promesse : un build, un fichier, l'environnement résolu à l'edge au dernier moment possible.

Le prérendu fige les valeurs d'environnement, alors résous-les à l'edge

Le prérendu fige un instant. Tout ce que ton application décide en regardant son environnement est décidé, au moment du prérendu, par rapport à l'environnement du build, qui est une adresse de bouclage et l'hôte réel de personne. Ainsi une valeur correcte à l'exécution devient fausse à l'instant même où elle est capturée, et elle échoue en silence, car la page prérendue s'affiche quand même, pointant simplement au mauvais endroit.

La correction générale est de ne pas résoudre du tout les valeurs propres à l'environnement pendant le prérendu. Émets un espace réservé, transporte-le intact à travers l'artefact statique, et résous-le à la couche qui voit réellement l'environnement, ce qui, pour une valeur par requête, est l'edge. C'est de la liaison tardive, appliquée à une chaîne dans un fichier HTML : laisse le trou ouvert à travers chaque étape qui ne connaît pas la réponse, et remplis-le à la seule étape qui la connaît.

Si vous préférez que la documentation et les ressources de votre propre fournisseur d'identité soient déjà servies correctement depuis l'edge, c'est l'une des nombreuses petites choses qu'Authagonal a déjà tranchées pour vous, afin que vous puissiez réserver le débat à votre propre produit.