De l'idée au déploiement en un git push
Un site de niveau professionnel, des performances imbattables, une stack européenne.
Le problème
Déployer un site web en 2025, c’est naviguer entre des compromis frustrants :
- Les plateformes américaines (Netlify, Vercel) — simples mais opaques, vos données chez les GAFAM
- Les CMS classiques (WordPress, Ghost) — puissants mais lourds, une surface d’attaque énorme à maintenir
- Les hébergements gratuits (GitHub Pages, GitLab Pages) — limités, pas de contrôle sur le cache ni les headers
Je voulais autre chose. Un site avec des performances de niveau professionnel : temps de chargement sous la seconde,
score Lighthouse à 100, CDN mondial. Une sécurité irréprochable : HTTPS strict, headers CSP, aucune surface d’attaque.
Et surtout, un déploiement automatique — un git push et c’est en ligne.
Le tout sans dépendre des géants américains du cloud.
Ce template est la solution. Cet article explique comment il fonctionne et comment l’utiliser.
Ce que cet article n’est pas : un tutoriel HTML/CSS, ni une formule magique pour le SEO. Je pars du principe que vous savez coder un site et que vous cherchez une infrastructure solide pour le déployer.
La stack choisie
Site statique plutôt que CMS
Avant de parler d’Hugo, posons la vraie question : pourquoi générer un site statique plutôt que d’utiliser un CMS classique ?
La sécurité d’abord. Un site statique n’a pas de backend, pas de base de données, pas de surface d’attaque. Pas de failles WordPress à patcher en urgence, pas d’injection SQL possible, pas de session à sécuriser. Le serveur ne fait que servir des fichiers — c’est tout.
La performance ensuite. Sans base de données à interroger ni PHP à exécuter, le temps de réponse se mesure en millisecondes. Ajoutez un CDN devant, et votre site répond depuis le point de présence le plus proche de l’utilisateur.
La résilience enfin. Un site statique sur CDN encaisse n’importe quelle charge. Pas de serveur qui tombe sous le trafic, pas de connexions base de données saturées.
J’aurais pu regarder du côté de Ghost, un CMS moderne en Node.js qui a le vent en poupe. Interface élégante, éditeur Markdown natif, gestion des abonnements intégrée. Mais Ghost reste un serveur à maintenir, des dépendances à mettre à jour, une base de données à sauvegarder. Pour un blog ou un site vitrine, c’est de la complexité inutile.
Hugo plutôt que Jekyll
Une fois le choix du statique fait, reste à choisir le générateur. Jekyll est le vétéran du domaine, propulsé par GitHub Pages. Mais il montre son âge :
- Ruby et ses gems : Un écosystème de dépendances à gérer, des conflits de versions fréquents
- Plugins obligatoires : Le multilingue ? Plugin. L’optimisation d’images ? Plugin. Le sitemap ? Plugin.
- Lenteur : Sur un site de quelques centaines de pages, les temps de build se comptent en minutes
Hugo prend le contre-pied :
- Un binaire unique : Pas de dépendances, pas de gestionnaire de paquets, ça fonctionne
- Batteries incluses : Multilingue natif, conversion WebP automatique, sitemap, RSS — tout est là sans plugin
- Rapidité brutale : Des milliers de pages compilées en secondes, pas en minutes
Le template inclut une configuration par défaut sensée (robots.txt automatique, meta generator désactivé pour ne pas exposer la stack, taxonomies désactivées). Tout est personnalisable selon vos besoins — consultez la documentation officielle Hugo pour découvrir toutes les options disponibles.
GitLab plutôt que GitHub
Qui dit site statique dit contenu versionné dans un repo Git. Reste à choisir où l’héberger.
GitHub appartient à Microsoft et reste closed source — ce n’est pas l’esprit que je recherche pour mes projets. Côté européen, des alternatives existent : Codeberg (Allemagne), Froggit ( France), ou des hébergeurs de GitLab/Gitea managé comme Stackhero ou Bearstech. Mais pour l’instant, je préfère utiliser GitLab.com directement — géré par ceux qui développent le projet, avec des fonctionnalités natives solides (CI/CD, registry Docker, state backend). Le jour où une alternative européenne atteindra un niveau de maturité suffisant, je reconsidérerai.
Bunny CDN
J’ai testé les CDN européens : KeyCDN (Suisse), Gcore (Luxembourg), OVHcloud (France), Myra Security (Allemagne), BlazingCDN (Pologne). Un seul a répondu à mes critères : Bunny.net (Slovénie).
- Prix abordable (~0.01€/GB)
- Edge Rules pour injecter des headers sans toucher au code
- Zones de stockage avec réplication configurable
- Gestion DNS intégrée (optionnelle)
- WAF et protection DDoS disponibles (non activés dans ce template)
- Et surtout : un provider Terraform officiel
C’est le seul CDN européen à proposer un provider Terraform. L’infrastructure as code dans le cloud européen a encore un gros retard à rattraper — en 2025, devoir cliquer dans une interface web pour gérer son infrastructure, c’est dommage.
Mention spéciale à Scaleway : offre cloud solide, outils modernes, provider Terraform mature… mais pas de CDN. Un trou béant dans le catalogue.
Dernier atout : Bunny propose un stockage objet intégré (storage zone) qui sert directement d’origine au CDN (pull zone). Pour un site statique, c’est idéal — on uploade les fichiers, le CDN les distribue, pas besoin de serveur origin externe. Mais rien n’empêche de configurer la pull zone devant un origin existant (serveur dédié, bucket S3, GitLab Pages…) si vous avez déjà une infrastructure en place.
OpenTofu plutôt que Terraform
Pourquoi de l’infrastructure as code pour un simple blog ? Habitude professionnelle, d’abord — je ne sais plus configurer autrement. Mais surtout, la configuration Bunny devient vite complexe : storage zone, pull zone, edge rules, DNS… Tout avoir dans un repo Git, versionné et reproductible, c’est la tranquillité d’esprit.
Le template utilise OpenTofu, le fork open source de Terraform. Depuis que HashiCorp a changé la licence de Terraform (BSL), la communauté s’est mobilisée autour d’OpenTofu. Au-delà de la licence, OpenTofu intègre plus rapidement de nouvelles fonctionnalités — et ne les cache pas derrière une version payante.
Just et direnv : le confort du développeur
Deux outils optionnels mais qui changent la vie au quotidien.
Just est un task runner sans les contraintes de Make : syntaxe claire, pas de logique de
dépendances de fichiers, messages d’erreur explicites. On tape just dev au lieu de chercher
hugo server --buildDrafts --navigateToChanged dans l’historique.
direnv charge automatiquement les variables d’environnement quand on entre dans le répertoire du projet, et les décharge quand on en sort. Idéal pour jongler entre plusieurs projets sans mélanger les credentials.
Combiné avec 1Password CLI, on peut versionner .envrc sans exposer de
secrets :
# .envrc
export BUNNYNET_API_KEY=$(op read "op://Vault/Bunny/api-key")
export GITLAB_TOKEN=$(op read "op://Vault/GitLab/token")
Le même principe fonctionne avec Bitwarden CLI ou tout gestionnaire de secrets en ligne de commande.
┌─────────────┐ git push ┌─────────────────────────────────┐
│ Dev local │──────────────────▶│ GitLab CI │
│ (Just, │ ├────────────────┬────────────────┤
│ direnv) │ │ Hugo │ OpenTofu │
└─────────────┘ │ (build) │ (infra Bunny) │
└───────┬────────┴───────┬────────┘
│ │
│ │
▼ ▼
┌────────────────────────────────┐
│ Bunny.net │
├────────────────┬───────────────┤
│ Storage Zone │ Pull Zone │
│ (fichiers) │ (CDN) │
└────────────────┴───────┬───────┘
│
▼
┌─────────────┐
│ Visiteurs │
└─────────────┘
Hugo génère le site, GitLab héberge le code et orchestre la CI, OpenTofu provisionne l’infrastructure Bunny (storage, CDN, DNS, headers), et bunny-transfer synchronise les fichiers. Just et direnv simplifient le travail en local.
Mise en œuvre
Voyons comment ces briques s’assemblent concrètement.
Structure du projet
Le template part d’une structure Hugo classique :
├── content/ # Contenu Markdown
├── layouts/ # Templates Hugo
│ ├── _default/ # Templates de base
│ ├── partials/ # Fragments réutilisables
│ └── index.html # Page d'accueil
├── assets/ # Traités par Hugo (CSS, JS, images)
├── static/ # Copiés tels quels (favicon, fonts)
└── hugo.toml # Configuration Hugo
Le template utilise un thème custom minimaliste directement dans layouts/. Pour un vrai site, vous préférerez
peut-être partir d’un thème existant
ou créer le vôtre.
À noter : assets/ contient les fichiers traités par Hugo (minification CSS, conversion images en WebP,
fingerprinting), tandis que static/ contient les fichiers copiés tels quels (favicon.ico, fonts). Les images vont dans
assets/ pour profiter de l’optimisation automatique.
Le template ajoute à cette base :
├── infra/ # Infrastructure OpenTofu
├── scripts/ # Scripts de déploiement
├── .gitlab-ci.yml # Pipeline CI/CD
├── justfile # Commandes Just
└── .envrc.example # Template pour direnv
Infrastructure as code
OpenTofu gère toute l’infrastructure Bunny :
infra/
├── backend.tf # State stocké dans GitLab
├── storage.tf # Zone de stockage
├── pullzone.tf # CDN et cache
├── dns.tf # Zone DNS (optionnelle)
├── edgerules.tf # Headers de sécurité
├── variables.tf # Configuration centralisée
└── outputs.tf # URLs et infos de déploiement
Le fichier pullzone.tf configure notamment une limite de bande passante à 1TB/mois (1000 Go × 0.01€ = ~10€). C’est une sécurité :
en cas de pic de trafic ou d’attaque DDoS, le site est coupé
plutôt que de laisser la facture s’envoler. Ajustez cette limite selon votre trafic réel.
State backend GitLab
Le state OpenTofu est stocké directement dans GitLab via son backend HTTP. Ça fonctionne bien, et surtout : on a déjà besoin de GitLab pour le repo et la CI. Pas la peine d’ajouter une dépendance vers un bucket S3 chez un provider de plus ou un compte Terraform Cloud :
# backend.tf
terraform {
backend "http" {}
}
Le backend HTTP est volontairement vide — toute la configuration est passée via des variables d’environnement
TF_HTTP_*
qu’OpenTofu reconnaît automatiquement. Cette approche permet d’utiliser le même code en CI et en local.
En CI, les variables TF_HTTP_* sont définies directement dans le job :
.tofu_common:
variables:
TF_HTTP_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/bunny-cdn"
TF_HTTP_LOCK_ADDRESS: "${TF_HTTP_ADDRESS}/lock"
TF_HTTP_UNLOCK_ADDRESS: "${TF_HTTP_ADDRESS}/lock"
TF_HTTP_LOCK_METHOD: "POST"
TF_HTTP_UNLOCK_METHOD: "DELETE"
TF_HTTP_USERNAME: "gitlab-ci-token"
TF_HTTP_PASSWORD: "${CI_JOB_TOKEN}"
GitLab fournit automatiquement CI_API_V4_URL, CI_PROJECT_ID et CI_JOB_TOKEN (token éphémère avec permissions
minimales).
En local, on reproduit la même configuration dans .envrc avec un Personal Access Token :
# .envrc
export TF_STATE_NAME="production"
export GITLAB_PROJECT_ID="12345678" # Visible dans Settings > General
# Token avec scope api, récupéré depuis 1Password
export GITLAB_TOKEN=$(op read "op://Vault/GitLab/token")
# Configuration du backend
export TF_HTTP_ADDRESS="https://gitlab.com/api/v4/projects/${GITLAB_PROJECT_ID}/terraform/state/${TF_STATE_NAME}"
export TF_HTTP_LOCK_ADDRESS="${TF_HTTP_ADDRESS}/lock"
export TF_HTTP_UNLOCK_ADDRESS="${TF_HTTP_ADDRESS}/lock"
export TF_HTTP_USERNAME="gautier" # Votre username GitLab
export TF_HTTP_PASSWORD="${GITLAB_TOKEN}"
Dans les deux cas, un simple tofu init suffit — OpenTofu détecte les variables automatiquement. Pour aller plus loin,
consultez
la documentation GitLab sur le state Terraform/OpenTofu.
DNS chez Bunny
Le template héberge la zone DNS chez Bunny par défaut. Pourquoi ? C’est le seul moyen de faire fonctionner le domaine
apex (example.com sans www) avec leur CDN.
Le problème est double. D’abord, Bunny CDN fonctionne par CNAME vers <pull-zone>.b-cdn.net — pas par adresse IP.
Ensuite, le standard DNS interdit les enregistrements CNAME sur l’apex d’un domaine. Pour contourner cette limitation,
certains fournisseurs proposent des enregistrements ALIAS ou ANAME, mais ce n’est pas universel — et Bunny
déconseille même cette approche car elle peut
dégrader les performances de routage. Bunny DNS gère ça nativement via CNAME flattening.
Leur service DNS est moderne (anycast, DNSSEC, propagation rapide) et le prix est dérisoire — ça ne m’a pas dérangé de migrer ma zone.
Attention tout de même, Bunny n’est pas registrar : le domaine s’achète ailleurs (OVH, Gandi, Infomaniak…) et on configure les nameservers Bunny + la clé DNSSEC chez le registrar.
Pour ceux qui préfèrent rester sur un sous-domaine (www.example.com) ou qui ont déjà un fournisseur DNS compatible,
la variable manage_dns permet de désactiver cette partie :
variable "manage_dns" {
description = "Create DNS zone in Bunny.net (set false to use external DNS)"
type = bool
default = true
}
Si désactivé, il suffit de créer un CNAME pointant vers <pull-zone>.b-cdn.net chez votre fournisseur DNS.
Pipeline CI/CD
La pipeline est divisée en 4 stages :
stages:
- build # Compilation Hugo
- validate # Validation OpenTofu (format, syntaxe)
- plan # Plan des changements infra
- deploy # Déploiement Hugo + Apply infra
Les jobs
hugo:build compile le site avec just build. L’artefact public/ est conservé pour le déploiement.
tofu:validate vérifie la syntaxe et le formatage des fichiers .tf — sans toucher à l’infrastructure.
tofu:plan génère un plan des changements à appliquer. Le plan est sauvegardé en artefact et visible dans l’interface GitLab (rapport Terraform intégré).
hugo:deploy envoie les fichiers vers Bunny Storage via
bunny-transfer, un outil Node.js spécialisé. Pourquoi pas un simple
curl ? bunny-transfer gère automatiquement la comparaison SHA256 et la synchronisation différentielle — seuls les
fichiers modifiés sont uploadés, et ceux supprimés localement le sont aussi sur le storage :
npx bunny-transfer@latest sync -k "$BUNNYNET_API_KEY" "./public" "$BUNNYNET_STORAGE_ZONE"
npx bunny-transfer@latest purge -k "$BUNNYNET_API_KEY" "$BUNNYNET_PULL_ZONE"
Le purge vide le cache CDN après le déploiement pour que les changements soient immédiatement visibles.
tofu:apply applique le plan généré précédemment — uniquement sur la branche principale.
Optimisations
Cache des binaires : Hugo et Just sont téléchargés une seule fois et mis en cache. Le cache GitLab CI ne fonctionne
que pour les fichiers à l’intérieur du projet — d’où le choix de .bin/, ajouté au PATH :
cache:
key: hugo-${HUGO_VERSION}-just-${JUST_VERSION}
paths:
- .bin/
Jobs conditionnels : Les jobs OpenTofu ne s’exécutent que si les fichiers .tf changent. Le reste du temps on se
contente de déployer le site.
rules:
- changes:
- infra/**/*.tf
Dépendances intelligentes : Le déploiement Hugo attend l’infrastructure, mais de manière optionnelle (pour les cas où l’infra n’a pas changé) :
hugo:deploy:
needs:
- hugo:build
- job: tofu:apply
optional: true
Variables d’environnement
Les mêmes variables sont utilisées par OpenTofu (via TF_VAR_*) et le script de déploiement. Cette harmonisation évite
les doublons et la confusion :
Variables Bunny (Terraform + déploiement) :
| Variable | Usage |
|---|---|
BUNNYNET_API_KEY | Clé API du compte |
BUNNYNET_STORAGE_ZONE | Nom de la zone de stockage |
BUNNYNET_PULL_ZONE | Nom de la pull zone |
BUNNYNET_DOMAIN | Domaine personnalisé |
Variables GitLab (state backend, en local uniquement) :
| Variable | Usage |
|---|---|
GITLAB_PROJECT_ID | ID numérique du projet |
GITLAB_TOKEN | Personal Access Token (scope api) |
TF_STATE_NAME | Nom du state (ex: production) |
Pour que la pipeline fonctionne, les variables Bunny doivent être déclarées dans GitLab (Settings > CI/CD > Variables).
Marquez BUNNYNET_API_KEY comme “Masked” pour éviter qu’elle apparaisse dans les logs. Les variables GitLab ne sont
nécessaires qu’en local — en CI, GitLab injecte automatiquement CI_JOB_TOKEN.
Edge Rules
Les Edge Rules de Bunny permettent d’injecter des headers de sécurité sans toucher au code applicatif :
# Tous les headers sont configurables via variables
variable "header_csp" {
default = "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'"
}
variable "header_hsts" {
default = "max-age=31536000; includeSubDomains; preload"
}
Les valeurs par défaut sont volontairement restrictives — un site statique n’a pas besoin de charger des scripts externes ou d’être intégré en iframe. Si vous utilisez des services tiers (analytics, fonts Google, widgets), vous devrez adapter ces headers via les variables Terraform.
Headers configurés par défaut :
| Header | Valeur | Protection |
|---|---|---|
| Content-Security-Policy | default-src 'self'... | XSS, injection |
| Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | Downgrade HTTPS |
| X-Frame-Options | DENY | Clickjacking |
| X-Content-Type-Options | nosniff | MIME sniffing |
| Referrer-Policy | strict-origin-when-cross-origin | Fuite de referrer |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Abus d’API |
D’autres Edge Rules gèrent la forme canonique des URLs :
- Redirection vers le domaine principal : le domaine CDN par défaut (
<pull-zone>.b-cdn.net, impossible à désactiver) et tout domaine alias redirigent vers le vrai domaine du site - Suppression de
index.html:/page/index.htmlredirige vers/page/ - Trailing slash :
/pageredirige vers/page/(sauf fichiers avec extension)
Pour commencer
Prérequis
Avant de commencer, assurez-vous d’avoir :
- Un compte Bunny.net avec une clé API
- Un compte GitLab (ou instance auto-hébergée)
- OpenTofu installé localement
- Just (optionnel — simplifie les commandes :
just dev,just deploy) - direnv (optionnel — charge automatiquement les variables d’environnement)
Premier déploiement
Clonez le template et configurez vos variables d’environnement :
git clone https://gitlab.com/gautier-levert/template-hugo-bunny.git my-site
cd my-site
cp .envrc.example .envrc
# Éditez .envrc avec vos valeurs (clé API Bunny, noms de zones, domaine)
source .envrc # ou laissez direnv le faire automatiquement
Déployez l’infrastructure Bunny (storage zone, pull zone, DNS, edge rules) :
cd infra
tofu init
tofu plan # Vérifiez ce qui va être créé
tofu apply # Confirmez pour créer les ressources
Configurez les variables CI/CD dans GitLab (Settings > CI/CD > Variables), puis poussez :
git remote set-url origin git@gitlab.com:votre-org/my-site.git
git push -u origin main
La pipeline se déclenche automatiquement : build Hugo, déploiement sur Bunny, purge du cache CDN.
Développement local
just dev # Serveur de développement avec hot reload
just build # Build de production
just deploy # Déploiement manuel (sans passer par la CI)
Limites de sécurité
Avant de mettre en production, quelques points à garder en tête.
En l’état, le template est vulnérable aux attaques par supply chain. On télécharge Hugo et Just sans vérifier les
checksums, on utilise bunny-transfer@latest, les images Docker ne sont pas épinglées par SHA256. Dans le contexte
actuel (incidents xz, polyfill.io, etc.), c’est une mauvaise pratique.
Pour un usage sérieux, il faudrait :
- Épingler les versions de tous les outils (
bunny-transfer@1.2.3,alpine:3.21@sha256:...) - Vérifier les checksums des binaires téléchargés (Hugo, Just, OpenTofu)
- Utiliser Renovate pour automatiser les mises à jour de manière intelligente (attendre quelques jours après publication, grouper les updates, etc.)
Cela dit, les risques restent limités sur ce type de projet : pas de base de données, pas de serveur avec accès SSH, pas de secrets utilisateurs. Le pire scénario réaliste est un défacement du site. À chacun d’évaluer la criticité de son projet et d’appliquer les mesures qui conviennent.
La stack ne fait pas tout
Une bonne infrastructure ne suffit pas. Ce template atteint 100/100 sur desktop immédiatement, mais seulement 84/100 sur mobile au départ. Pour atteindre le score parfait, deux optimisations ont été nécessaires :
Fonts préchargées réduites — Passer de 3 à 2 fonts préchargées (Inter Regular + Anta uniquement). Les variantes Bold se chargent via le CSS, sans bloquer le rendu initial. Gain : ~400 ms.
font-display: optional — Évite le flash de texte non stylé (FOUT) de swap. Le navigateur affiche la font custom
si elle arrive vite, sinon utilise la system font. Résultat : pas de flash visuel.
Résultat : Mobile 84/100 → 100/100. LCP 3,8s → 0,9s (-76%).
Leçon : La stack pose les bases, mais les détails d’implémentation font la différence. Un site Hugo mal optimisé peut scorer 60/100 malgré Bunny CDN. Un site bien optimisé sur GitHub Pages peut scorer 95/100 malgré ses limitations. Les deux sont nécessaires.
Conclusion
Ce blog tourne sur ce template. Score Lighthouse à 100, temps de chargement sous la seconde, zéro maintenance serveur.
Prêt à essayer ? Clonez le template, déployez-le en 10 minutes, et adaptez-le à vos besoins. Si vous rencontrez un problème ou avez une suggestion d’amélioration, ouvrez une issue sur GitLab.
FAQ
Pourquoi pas Netlify ou Vercel ? Ce sont d’excellents services, parfaitement adaptés aux sites statiques — c’est même leur spécialité. Mais ils sont américains. Des alternatives européennes existent (IONOS Deploy Now, Scalingo, statichost), mais moins matures. Ce template prend une approche différente : plutôt qu’une plateforme tout-en-un, on assemble des briques indépendantes (Hugo, Bunny, GitLab) — plus de contrôle, plus de flexibilité.
Quel coût mensuel réel ? Pour un blog avec quelques milliers de visiteurs par mois : environ 1€. Bunny facture ~0.01€/Go avec un minimum de 1$/mois. Un site statique optimisé reste dans cette fourchette basse.
Pourquoi pas GitHub Pages ou GitLab Pages ? Ces solutions sont gratuites et simples, mais limitées pour un usage professionnel. GitHub Pages impose une limite de 100 Go/mois de bande passante et 1 Go de taille de site. Plus gênant : aucun contrôle sur les headers HTTP (pas de CSP, HSTS configurables), pas de gestion du cache (TTL, invalidation), pas de edge rules. Pour un site vitrine d’entreprise ou un blog avec du trafic, ces contraintes deviennent vite bloquantes — sans parler du fait que ce sont des services américains.
Le template fonctionne-t-il avec un autre générateur que Hugo ?
Oui, avec quelques adaptations. La partie infrastructure (Bunny, GitLab CI) est indépendante du générateur. Il suffit
de modifier le job hugo:build pour appeler un autre outil (Jekyll, Eleventy, Astro…).