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) :

VariableUsage
BUNNYNET_API_KEYClé API du compte
BUNNYNET_STORAGE_ZONENom de la zone de stockage
BUNNYNET_PULL_ZONENom de la pull zone
BUNNYNET_DOMAINDomaine personnalisé

Variables GitLab (state backend, en local uniquement) :

VariableUsage
GITLAB_PROJECT_IDID numérique du projet
GITLAB_TOKENPersonal Access Token (scope api)
TF_STATE_NAMENom 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 :

HeaderValeurProtection
Content-Security-Policydefault-src 'self'...XSS, injection
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preloadDowngrade HTTPS
X-Frame-OptionsDENYClickjacking
X-Content-Type-OptionsnosniffMIME sniffing
Referrer-Policystrict-origin-when-cross-originFuite de referrer
Permissions-Policycamera=(), 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.html redirige vers /page/
  • Trailing slash : /page redirige 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…).