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

> Migration d'un site statique de Docker sur Coolify vers Cloudflare Workers : en-têtes, redirections, images OG, analytics et bascule DNS, chiffres à l'appui.

Alain Deléglise, 28 septembre 2026. Source : https://www.expertiseby.me/blog/migrer-site-statique-docker-cloudflare-workers

## 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 Coolify (Plateforme auto-hébergée de déploiement d'applications, une alternative libre à Heroku ou Vercel), un proxy Envoy (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 Cloudflare Workers static assets (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.

<figure class="viz" aria-label="Chiffres clés de la migration">
  <div class="viz-head"><span>migration · expertiseby.me</span><span><b>●</b> mesuré le 28/09/2026</span></div>
  <div class="viz-stats">
    <div class="viz-stat"><span class="viz-stat-value"><s>226</s> 73 <small>lignes</small></span><span class="viz-stat-label">de configuration de service</span></div>
    <div class="viz-stat"><span class="viz-stat-value"><s>4</s> 1 <small>composant</small></span><span class="viz-stat-label">traversé par chaque requête</span></div>
    <div class="viz-stat"><span class="viz-stat-value">48 <small>fichiers</small></span><span class="viz-stat-label">1,1 Mo publiés au total</span></div>
    <div class="viz-stat"><span class="viz-stat-value">0 <small>€</small></span><span class="viz-stat-label">par requête de fichier statique</span></div>
  </div>
</figure>

## 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, Cloudflare Access (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.

<figure class="viz" aria-label="Chemin d'une requête avant et après la migration">
  <div class="viz-head"><span>trace · GET /blog</span><span><b class="viz-key is-before">■</b> avant <b class="viz-key is-after">■</b> après</span></div>
  <div class="viz-flow">
    <div class="viz-lane is-before">
      <span class="viz-lane-label"><b>Avant</b> · Docker sur Coolify</span>
      <ol>
        <li class="viz-node"><b>Navigateur</b><span>HTTPS</span></li>
        <li class="viz-link" aria-hidden="true"></li>
        <li class="viz-node"><b>Cloudflare</b><span>proxy, TLS, Access</span></li>
        <li class="viz-link" aria-hidden="true"></li>
        <li class="viz-node"><b>VPS Coolify</b><span>conteneur Docker</span></li>
        <li class="viz-link" aria-hidden="true"></li>
        <li class="viz-node"><b>Envoy</b><span>:3000 en-têtes, logs</span></li>
        <li class="viz-link" aria-hidden="true"></li>
        <li class="viz-node"><b>busybox httpd</b><span>:8080 fichiers</span></li>
      </ol>
    </div>
    <div class="viz-lane is-after">
      <span class="viz-lane-label"><b>Après</b> · Workers static assets</span>
      <ol>
        <li class="viz-node"><b>Navigateur</b><span>HTTPS</span></li>
        <li class="viz-link" aria-hidden="true"></li>
        <li class="viz-node"><b>Cloudflare</b><span>TLS, Access, en-têtes, fichiers</span></li>
      </ol>
    </div>
  </div>
  <figcaption>Le build reste identique dans les deux cas : un script Python génère les pages, les images OG et le sitemap.</figcaption>
</figure>

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.

<figure class="viz" aria-label="Lignes de configuration avant et après">
  <div class="viz-head"><span>config · lignes par fichier</span><span>total</span></div>
  <div class="viz-bars">
    <div class="viz-row is-before">
      <span class="viz-row-label">Docker + Envoy</span>
      <span class="viz-track"><span class="viz-seg" style="--w: 80.5%" title="envoy.yaml : 183 lignes"></span><span class="viz-seg" style="--w: 13.3%" title="Dockerfile : 30 lignes"></span><span class="viz-seg" style="--w: 5.7%" title="httpd.conf : 13 lignes"></span></span>
      <span class="viz-row-value">226</span>
      <span class="viz-row-parts">envoy.yaml 183 · Dockerfile 30 · httpd.conf 13</span>
    </div>
    <div class="viz-row is-after">
      <span class="viz-row-label">Workers</span>
      <span class="viz-track"><span class="viz-seg" style="--w: 19.9%" title="_headers : 45 lignes"></span><span class="viz-seg" style="--w: 8.4%" title="wrangler.jsonc : 19 lignes"></span><span class="viz-seg" style="--w: 4%" title="_redirects : 9 lignes"></span></span>
      <span class="viz-row-value">73</span>
      <span class="viz-row-parts">_headers 45 · wrangler.jsonc 19 · _redirects 9</span>
    </div>
  </div>
  <figcaption>Commentaires compris. Les barres partagent la même échelle.</figcaption>
</figure>

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

```jsonc
{
  "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.

```text
/*
  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 OG (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 OpenPanel (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 LCEN (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 :

```bash
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)'
```

<figure class="viz" aria-label="Contrôles passés sur www.expertiseby.me après la bascule">
  <div class="viz-head"><span>checks · www.expertiseby.me</span><span><b>26 / 26</b> conformes</span></div>
  <ul class="viz-board">
    <li class="viz-check"><span>/</span><b>200</b></li>
    <li class="viz-check"><span>/blog</span><b>200</b></li>
    <li class="viz-check"><span>/applications/</span><b>200</b></li>
    <li class="viz-check"><span>/applications</span><b>307</b></li>
    <li class="viz-check"><span>/applications.html</span><b>301</b></li>
    <li class="viz-check"><span>/consultant-devops-lille.html</span><b>301</b></li>
    <li class="viz-check"><span>/consultant-devops-lille/</span><b>200</b></li>
    <li class="viz-check"><span>/sre-freelance-hauts-de-france/</span><b>200</b></li>
    <li class="viz-check"><span>/mentions-legales/</span><b>200</b></li>
    <li class="viz-check"><span>/politique-de-confidentialite/</span><b>200</b></li>
    <li class="viz-check"><span>/robots.txt</span><b>200</b></li>
    <li class="viz-check"><span>/sitemap.xml</span><b>200</b></li>
    <li class="viz-check"><span>/og-default.png</span><b>200</b></li>
    <li class="viz-check"><span>/favicon.ico</span><b>200</b></li>
    <li class="viz-check"><span>/fonts/NerdSymbols-Subset.woff2</span><b>200</b></li>
    <li class="viz-check"><span>/chemin-inconnu</span><b>404</b></li>
    <li class="viz-check"><span>/blog/backstage-idp-startup-guide-pratique</span><b>200</b></li>
    <li class="viz-check"><span>/blog/og/backstage-idp-startup-guide-pratique.png</span><b>200</b></li>
    <li class="viz-check"><span>/blog/memoire-persistante-agents-ia-pgvector</span><b>200</b></li>
    <li class="viz-check"><span>/blog/og/memoire-persistante-agents-ia-pgvector.png</span><b>200</b></li>
    <li class="viz-check"><span>/blog/opentelemetry-ecosysteme-observabilite</span><b>200</b></li>
    <li class="viz-check"><span>/blog/og/opentelemetry-ecosysteme-observabilite.png</span><b>200</b></li>
    <li class="viz-check"><span>/blog/paseo-piloter-agents-ia-distance</span><b>200</b></li>
    <li class="viz-check"><span>/blog/og/paseo-piloter-agents-ia-distance.png</span><b>200</b></li>
    <li class="viz-check"><span>/blog/serveurs-gpu-bare-metal-llm-local</span><b>200</b></li>
    <li class="viz-check"><span>/blog/og/serveurs-gpu-bare-metal-llm-local.png</span><b>200</b></li>
  </ul>
  <figcaption>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.</figcaption>
</figure>

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.

<figure class="viz" aria-label="Durées du build et du premier déploiement">
  <div class="viz-head"><span>deploy · durées mesurées</span><span>secondes</span></div>
  <div class="viz-bars">
    <div class="viz-row is-after">
      <span class="viz-row-label">build.py</span>
      <span class="viz-track"><span class="viz-seg" style="--w: 12.5%"></span></span>
      <span class="viz-row-value">0,90</span>
    </div>
    <div class="viz-row is-after">
      <span class="viz-row-label">envoi, 44 fichiers</span>
      <span class="viz-track"><span class="viz-seg" style="--w: 24.4%"></span></span>
      <span class="viz-row-value">1,76</span>
    </div>
    <div class="viz-row is-after">
      <span class="viz-row-label">publication</span>
      <span class="viz-track"><span class="viz-seg" style="--w: 100%"></span></span>
      <span class="viz-row-value">7,20</span>
    </div>
    <div class="viz-row is-after">
      <span class="viz-row-label">activation</span>
      <span class="viz-track"><span class="viz-seg" style="--w: 27.6%"></span></span>
      <span class="viz-row-value">1,99</span>
    </div>
  </div>
  <figcaption>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.</figcaption>
</figure>

## 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.

<figure class="viz" aria-label="Poids du site publié par type de fichier">
  <div class="viz-head"><span>dist · poids par type</span><span>Kio</span></div>
  <div class="viz-bars">
    <div class="viz-row is-after"><span class="viz-row-label">HTML (14)</span><span class="viz-track"><span class="viz-seg" style="--w: 100%"></span></span><span class="viz-row-value">357</span></div>
    <div class="viz-row is-after"><span class="viz-row-label">polices (8)</span><span class="viz-track"><span class="viz-seg" style="--w: 85.5%"></span></span><span class="viz-row-value">305</span></div>
    <div class="viz-row is-after"><span class="viz-row-label">images (9)</span><span class="viz-track"><span class="viz-seg" style="--w: 66.4%"></span></span><span class="viz-row-value">237</span></div>
    <div class="viz-row is-after"><span class="viz-row-label">CSS et JS (5)</span><span class="viz-track"><span class="viz-seg" style="--w: 33.2%"></span></span><span class="viz-row-value">119</span></div>
    <div class="viz-row is-after"><span class="viz-row-label">autres (12)</span><span class="viz-track"><span class="viz-seg" style="--w: 30.2%"></span></span><span class="viz-row-value">108</span></div>
  </div>
  <figcaption>48 fichiers, 1 126 Kio. Entre parenthèses, le nombre de fichiers. « Autres » regroupe Markdown, JSON, XML, texte et fichiers de configuration.</figcaption>
</figure>

## 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](/#contact)._
