Retour au blog

Quitter Docker pour Cloudflare Workers : migrer un site statique sans rien casser

Migration d'expertiseby.me d'un conteneur Docker (Envoy + busybox) sur Coolify vers Cloudflare Workers static assets. En-têtes, redirections, images OG, analytics, mentions légales et bascule DNS, chiffres à l'appui.

Un site qui ne fait rien, servi par quatre composants

Ce site ne fait rien. C'est un compliment. Du HTML généré au build, du CSS, quelques polices, des images. Pas une ligne de code exécutée côté serveur, pas de base de données, pas de session.

Jusqu'au 28 septembre 2026, chaque requête traversait pourtant quatre composants : le proxy Cloudflare, un conteneur Docker déployé par Plateforme auto-hébergée de déploiement d'applications, une alternative libre à Heroku ou Vercel, un proxy Proxy HTTP de la CNCF, utilisé ici pour réécrire les URL, poser les en-têtes de sécurité et écrire les logs d'accès sur le port 3000, puis un serveur busybox httpd sur le port 8080. Quatre composants pour servir des fichiers qui ne changent qu'au déploiement.

Le site sert désormais depuis Fonctionnalité de Cloudflare Workers qui sert des fichiers statiques depuis le réseau de Cloudflare. Un Worker « assets-only » ne contient aucun script : il n'y a rien à exécuter. Plus de serveur, plus de conteneur, plus de proxy à maintenir. Voici ce que la migration a vraiment demandé, ce qui a cassé en route et ce qu'elle coûte.

migration · expertiseby.me● mesuré le 28/09/2026
226 73 lignesde configuration de service
4 1 composanttraversé par chaque requête
48 fichiers1,1 Mo publiés au total
0 €par requête de fichier statique

Avant, après : le chemin d'une requête

Le schéma dit l'essentiel. Avant, Cloudflare servait déjà de porte d'entrée : TLS, cache, protection, Service Zero Trust de Cloudflare qui exige une authentification avant d'atteindre un nom d'hôte. Il relayait ensuite chaque requête jusqu'au serveur. Après, le trajet s'arrête à la porte d'entrée.

trace · GET /blog■ avant ■ après
Avant · Docker sur Coolify
  1. NavigateurHTTPS
  2. Cloudflareproxy, TLS, Access
  3. VPS Coolifyconteneur Docker
  4. Envoy:3000 en-têtes, logs
  5. busybox httpd:8080 fichiers
Après · Workers static assets
  1. NavigateurHTTPS
  2. CloudflareTLS, Access, en-têtes, fichiers
Le build reste identique dans les deux cas : un script Python génère les pages, les images OG et le sitemap.

Côté facture, la documentation de Cloudflare est explicite : les requêtes vers des fichiers statiques sont gratuites et illimitées, sur l'offre gratuite comme sur l'offre payante. Seules les requêtes qui exécutent un script Worker comptent. Ici, il n'y en a aucune.

Qui remplace quoi

Envoy faisait cinq choses. Chacune a trouvé un équivalent, sauf une.

Rôle d'Envoy Équivalent Workers Fichier
En-têtes de sécurité (CSP, HSTS, nosniff...) Règles d'en-têtes par chemin _headers
Cache long sur les CSS et JS versionnés Cache-Control: immutable par chemin _headers
URL propres (/blog/<slug> vers <slug>.html) html_handling: auto-trailing-slash wrangler.jsonc
Anciennes URL .html redirigées Redirections 301 statiques _redirects
Page 404 not_found_handling: 404-page wrangler.jsonc
Logs d'accès JSON Aucun

La configuration de service passe de 226 à 73 lignes. Le gros du volume, c'était Envoy : 183 lignes de YAML pour des réécritures et des en-têtes.

config · lignes par fichiertotal
Docker + Envoy 226 envoy.yaml 183 · Dockerfile 30 · httpd.conf 13
Workers 73 _headers 45 · wrangler.jsonc 19 · _redirects 9
Commentaires compris. Les barres partagent la même échelle.

Le fichier de configuration du Worker tient sur un écran. Il ne déclare aucun script, seulement un dossier à servir et deux comportements :

{
  "name": "expertiseby-me",
  "compatibility_date": "2026-09-28",
  "assets": {
    "directory": "./dist",
    // foo.html est servi sur /foo, foo/index.html sur /foo/
    "html_handling": "auto-trailing-slash",
    // Les chemins inconnus reçoivent dist/404.html avec un statut 404
    "not_found_handling": "404-page",
  },
}

Les en-têtes se déclarent par motif de chemin. Les règles qui correspondent à une même requête se combinent, d'où une précaution : ne jamais poser Cache-Control sur /*, sinon il s'ajoute à celui des fichiers versionnés.

/*
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  X-Content-Type-Options: nosniff
  Content-Security-Policy: default-src 'self'; ...

/style.css
  Cache-Control: public, max-age=31536000, immutable

Ce qui a cassé (et comment)

Les images OG sans ImageMagick

Chaque article a une Image affichée quand un lien est partagé sur LinkedIn, Slack ou ailleurs, déclarée par la balise og:image, générée au build. L'image Docker les dessinait avec ImageMagick. L'image de build de Workers Builds (Ubuntu 24.04) ne l'embarque pas.

Le script de build dessine donc désormais la même mise en page avec Pillow, la bibliothèque d'images de Python. Ce chemin de code n'avait jamais tourné nulle part. Il a été vérifié au pixel plutôt qu'à l'œil : logo à la valeur maximale de 255 (blanc pur), titres centrés à 3 pixels près, pied de page à un contraste de 6,3:1, conforme au niveau AA.

Les URL à barre oblique finale

Certaines pages vivent sous une URL qui finit par une barre oblique : /applications/, /consultant-devops-lille/. Envoy les réécrivait vers le bon fichier. Avec auto-trailing-slash, Workers sert applications/index.html sur /applications/ et redirige /applications vers la version avec barre oblique.

Le build écrit donc ces pages sous la forme <nom>/index.html. Les anciennes URL en .html, encore présentes dans des liens externes, partent en redirection 301 via _redirects.

Les logs d'accès disparaissent

C'est la seule fonction d'Envoy sans équivalent. Une requête servie depuis les fichiers statiques n'invoque aucun Worker : elle n'apparaît donc pas dans les logs de Workers. Il reste les statistiques HTTP de la zone Cloudflare, agrégées. Plus de ligne JSON par requête.

Pour un site vitrine, c'est acceptable. Pour un service soumis à une obligation de traçabilité, ce serait bloquant.

Mesurer l'audience sans serveur

La mesure d'audience reposait sur Outil d'analyse d'audience open source, auto-hébergeable et sans cookie, auto-hébergé sur un sous-domaine. Son script était injecté au build avec un identifiant client, et des attributs data-track suivaient les clics sur les boutons de prise de rendez-vous.

Avec un hébergement sans serveur, garder une instance OpenPanel revenait à garder un serveur pour une seule fonction. Elle a laissé la place à Cloudflare Web Analytics en configuration automatique : Cloudflare injecte lui-même son script dans le HTML servi sur le nom d'hôte, et les mesures repartent vers /cdn-cgi/rum, sur le même domaine. Aucune variable de build, aucun extrait de code dans le dépôt, et la CSP autorisait déjà son script.

Critère OpenPanel auto-hébergé Cloudflare Web Analytics
Cookies Aucun Aucun, ni stockage local
Serveur à maintenir Oui Non
Suivi des clics (CTA) Oui, via data-track Non
Conservation Fixée par l'instance 6 mois consultables, données brutes 7 jours
Lieu de traitement Mon infrastructure Cloudflare, Inc. (États-Unis)

Le vrai renoncement est le suivi des clics : savoir quel bouton mène à une prise de rendez-vous. Le reste est un gain net.

Le volet juridique que tout le monde oublie

Changer d'hébergeur, c'est aussi changer ses mentions légales. L'article 6-III-1 de la Loi pour la confiance dans l'économie numérique du 21 juin 2004, qui impose notamment d'identifier l'éditeur et l'hébergeur d'un site impose d'identifier l'hébergeur : nom, adresse, téléphone. L'ancien texte désignait Hetzner, en Allemagne. Le nouveau désigne Cloudflare, Inc., à San Francisco.

La politique de confidentialité suit le même chemin. Elle affirmait un hébergement « intégralement situé dans l'Union européenne ». Ce n'est plus vrai : les requêtes, les données de connexion et les mesures d'audience peuvent être traitées hors de l'UE, dans le cadre du Data Privacy Framework UE-États-Unis et des clauses contractuelles types. La page le dit désormais.

Rien de spectaculaire. Mais un site qui promet une chose dans sa politique de confidentialité et en fait une autre dans son infrastructure a un problème, même s'il ne sert que des fichiers statiques.

La bascule DNS, et comment revenir en arrière

Un Worker se rattache à un nom d'hôte par un domaine personnalisé. Cloudflare crée alors l'enregistrement DNS et le certificat. Il refuse en revanche de le faire si le nom porte déjà un enregistrement CNAME. La bascule tient donc en quatre étapes :

  1. Noter l'enregistrement www existant : type, cible, statut du proxy. C'est tout le plan de retour arrière.
  2. Le supprimer. Le site est injoignable à partir de cet instant.
  3. Ajouter www.expertiseby.me comme domaine personnalisé du Worker.
  4. Vérifier les pages, les redirections et les en-têtes.

Le retour arrière consiste à recréer l'enregistrement noté. Le conteneur Coolify reste en service tant que la nouvelle version n'a pas fait ses preuves.

Deux détails méritent l'attention. D'abord, Cloudflare Access s'applique au nom d'hôte, quel que soit ce qui sert derrière : une application Access existante continue de s'appliquer sans modification. Ensuite, l'apex. _redirects ne sait pas filtrer par nom d'hôte, la redirection de expertiseby.me vers www passe donc par une règle de redirection de la zone, adossée à un enregistrement proxifié. Juste après la bascule, l'apex répondait 200 au lieu de rediriger. Le genre de détail qu'on ne voit qu'en le testant.

Vérifier à trois niveaux

Chaque niveau ajoute une couche de réalisme :

  1. Un serveur local qui imite le routage de Workers (dev_server.py --cloudflare) : rapide, lancé aussi en intégration continue.
  2. wrangler dev, qui sert le dossier avec le vrai routeur de fichiers de Cloudflare.
  3. L'URL *.workers.dev après un premier déploiement, marquée noindex pour rester hors des moteurs de recherche.

Le même jeu de contrôles tourne à chaque niveau, puis une dernière fois sur le domaine de production :

check() {
  status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$1")
  echo "$1 -> $status (attendu $2)"
  [ "$status" = "$2" ]
}
check / 200
check /applications 307
check /applications.html 301
check /page-qui-n-existe-pas 404
curl -sI "$BASE/" | grep -iE '^(content-security-policy|strict-transport-security)'
checks · www.expertiseby.me26 / 26 conformes
  • /200
  • /blog200
  • /applications/200
  • /applications307
  • /applications.html301
  • /consultant-devops-lille.html301
  • /consultant-devops-lille/200
  • /sre-freelance-hauts-de-france/200
  • /mentions-legales/200
  • /politique-de-confidentialite/200
  • /robots.txt200
  • /sitemap.xml200
  • /og-default.png200
  • /favicon.ico200
  • /fonts/NerdSymbols-Subset.woff2200
  • /chemin-inconnu404
  • /blog/backstage-idp-startup-guide-pratique200
  • /blog/og/backstage-idp-startup-guide-pratique.png200
  • /blog/memoire-persistante-agents-ia-pgvector200
  • /blog/og/memoire-persistante-agents-ia-pgvector.png200
  • /blog/opentelemetry-ecosysteme-observabilite200
  • /blog/og/opentelemetry-ecosysteme-observabilite.png200
  • /blog/paseo-piloter-agents-ia-distance200
  • /blog/og/paseo-piloter-agents-ia-distance.png200
  • /blog/serveurs-gpu-bare-metal-llm-local200
  • /blog/og/serveurs-gpu-bare-metal-llm-local.png200
Codes HTTP attendus : 200 servi, 301 et 307 redirigés, 404 page d'erreur. S'y ajoutent six en-têtes de sécurité présents sur la page d'accueil.

Le cycle complet est court. Le build génère le site en moins d'une seconde, et wrangler n'envoie que les fichiers modifiés depuis la version précédente.

deploy · durées mesuréessecondes
build.py 0,90
envoi, 44 fichiers 1,76
publication 7,20
activation 1,99
Durées affichées par wrangler 4.142 lors du premier déploiement, build mesuré avec time. Wrangler ne précise pas si l'envoi est compris dans la publication : les barres ne s'additionnent pas.

Ce que sert vraiment le site

Un dernier graphique, pour garder les pieds sur terre. Le site entier pèse 1,1 Mo, dont près d'un tiers de HTML et plus d'un quart de polices. C'est ce volume-là qui justifiait un conteneur, un proxy et un serveur.

dist · poids par typeKio
HTML (14)357
polices (8)305
images (9)237
CSS et JS (5)119
autres (12)108
48 fichiers, 1 126 Kio. Entre parenthèses, le nombre de fichiers. « Autres » regroupe Markdown, JSON, XML, texte et fichiers de configuration.

Le vrai coût

La migration supprime un serveur, un conteneur, un proxy et un outil d'analytics à maintenir. Elle remplace 226 lignes de configuration par 73. Elle ramène le coût des requêtes à zéro.

Elle a aussi un prix, qu'il faut regarder en face :

  • La concentration. DNS, proxy, contrôle d'accès, hébergement et mesure d'audience chez un seul fournisseur. Le jour où Cloudflare tombe, tout tombe ensemble. C'était déjà en partie le cas avant, puisque tout le trafic passait par son proxy.
  • Les logs bruts. Il n'y a plus de ligne par requête. Pour enquêter sur un comportement précis, il faut se contenter d'agrégats.
  • L'hébergement hors UE. Pour un site vitrine sans données personnelles côté serveur, c'est un point à documenter. Pour certains clients publics ou réglementés, c'est un critère éliminatoire.

Ma recommandation est simple. Si le site est réellement statique et passe déjà par Cloudflare, Workers static assets s'impose : le serveur ne servait qu'à relayer ce que Cloudflare sait servir lui-même. Si vous avez besoin de logs d'accès exhaustifs, d'un hébergement contractuellement européen ou de la moindre logique côté serveur, gardez votre conteneur, ou préparez-vous à ajouter un script Worker et à payer ce qu'il consomme.

Dans tous les cas, prévoyez une heure pour les mentions légales. C'est la partie de la migration que personne ne teste.

Vous voulez alléger l'infrastructure d'un site ou d'une plateforme sans rien casser en production ? Parlons-en.