1. Pourquoi refuser l’over-engineering ?
Les blogs sous Next.js ou WordPress pèsent fréquemment plusieurs mégaoctets pour afficher quelques paragraphes de texte. En tant qu’ingénieur plateforme travaillant sur des systèmes industriels bas-niveau et du legacy, mon cahier des charges pour ce journal était mesuré :
- Un binaire unique et rapide : Éviter un dossier
node_modulesde plusieurs centaines de mégaoctets ou des dépendances éphémères juste pour compiler du Markdown. - Surface d’attaque zéro & Zéro maintenance : Pas de base de données, pas de moteur PHP ou Node.js à patcher chaque semaine en urgence suite à une CVE. Le Markdown statique est intrinsèquement invulnérable.
- La ressource pure (Zéro dépendance d’infra) : Pas de composants React ou Vue.js lourds. Du HTML/CSS brut, lisible et responsive, capable d’être servi par n’importe quel serveur HTTP (Apache, Nginx, bucket S3, FTP, ou même directement via le protocole
file://) sans nécessiter de reverse-proxy ou d’application serveur complexe. - Multilingue natif : Basculer entre français (
.fr.md) et anglais (.md) sans ajouter une suite de plugins fragiles. - Tout sous Git : Le contenu est traité comme du code, versionné et déployé de manière déterministe par CI/CD.
Cet article pose les bases : mon setup de départ avec GoHugo, le thème PaperMod et GitHub Pages. C’est ma ligne de base avant d’aller tester mes propres briques d’infra auto-hébergées Ouvrage (comme ouvrage-lutrin).
2. Le moteur : GoHugo + PaperMod
Pourquoi GoHugo ?
GoHugo compile des milliers de pages en quelques millisecondes. Écrit en Go, c’est un binaire unique et léger. Il consomme très peu de mémoire et conserve des builds stables à travers le temps.
Personnalisation : Thème PaperMod
J’ai choisi PaperMod pour son esthétique sobre centrée sur le texte, son mode sombre et la coloration syntaxique Chroma intégrée.
# Extrait de hugo.yaml
markup:
highlight:
codeFences: true
guessSyntax: true
lineNos: true
style: "monokai"
2.1 Le piège du rendu Mermaid & MathJax (Overriding propre sans altérer le sous-module)
C’est ici qu’il faut être vigilant. La doc officielle d’Hugo préconise d’inclure le script Mermaid dans le modèle baseof.html avec un petit .Store.Get "hasMermaid".
Sauf qu’en pratique avec PaperMod (qu’on garde sous forme de submodule Git propre pour éviter la dette), si tu suis bêtement la doc officielle… tu te retrouves avec une page au HTML désespérément vide (<body></body>), sans le moindre message d’erreur au build. C’est le genre de bug silencieux qui te fait perdre 20 minutes à chercher pourquoi ton serveur dev ne recrache rien.
La solution propre consiste à utiliser les hooks de surcharge natifs d’Hugo et les partials d’extension de PaperMod :
Le Codeblock Render Hook (
layouts/_markup/render-codeblock-mermaid.html) :
Il intercepte les blocsmermaid, échappe le HTML et lève le drapeau global :<pre class="mermaid"> {{ .Inner | htmlEscape | safeHTML }} </pre> {{ .Page.Store.Set "hasMermaid" true }}La surcharge de footer PaperMod (
layouts/_partials/extend_footer.html) :
Plutôt que d’aller hacker le thème, on surcharge le partial de footer pour n’injecter le script ESM Mermaid que si la page contient réellement un diagramme :{{ if .Store.Get "hasMermaid" }} <script type="module"> import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; mermaid.initialize({ startOnLoad: true }); </script> {{ end }}Le hook d’en-tête pour les maths (
layouts/_partials/extend_head.html) :
Même punition pour MathJax : injection uniquement si la page déclaremath: truedans son en-tête :{{ if .Param "math" }} {{ partialCached "math.html" . }} {{ end }}
Résultat : zéro JS inutile sur les pages de texte pur, et un submodule PaperMod qui reste 100% intact.
2.2 Et si on a besoin de dynamique ? (Serverless JS & Wasm client-side)
Statut statique ne veut pas dire site figé. En cas de besoin de fonctionnalités interactives (recherche plein texte, calculettes d’infrastructure, simulateurs de protocoles), l’architecture statique permet d’injecter du dynamique sans installer de backend :
- Recherche Plein Texte Indexée (Fuse.js) : PaperMod génère un simple index JSON à la compilation (
index.json). La recherche s’exécute à 100% côté client, en pur JS, sans base de données SQL ou cluster Elastic. - Moteurs de calcul embarqués (WASM) : Si l’on souhaite faire tourner des algorithmes complexes (parseurs d’AST, décodeurs de paquets), on peut directement compiler nos modules Go (
ouvrage-kern-go) en composants WebAssembly et les exécuter dans le navigateur sans aucun serveur backend.
2.3 Blog vs Documentation : Pourquoi GoHugo ici, mais Zensical (MkDocs) pour les projets ?
Il est important de séparer l’architecture de journal de l’architecture de documentation.
- GoHugo (Pour ce journal personal
g.pineda.me) : Idéal pour un fil chronologique, un blog bilingue, des réflexions d’ingénierie et des récits d’architecture. Hugo excelle dans la gestion des archives, des tags et des flux temporels. - Zensical / Material for MkDocs (Pour la documentation d’Ouvrage Systems) : La documentation technique de nos projets (comme
ouvrage-doc-etlouocalque) requiert une navigation hiérarchique stricte (arbre de navigation à plusieurs niveaux, recherche pondérée sur la doc API, onglets de code multi-langages, intégration des ADRs). Pour cet usage, Zensical (la refonte moderne de Material for MkDocs) est l’outil spécialisé retenu.
Utiliser le bon outil pour le bon type d’information est un principe clé d’ingénierie : du chronologique pour le journal, du hiérarchique pour la documentation produit.
3. Structure de dossiers bilingue
Hugo gère nativement le multilingue en ajoutant le suffixe de langue aux fichiers Markdown. La structure reste simple et à plat :
content/
├── about.md # Page À propos en anglais
├── about.fr.md # Page À propos en français
└── posts/
├── 2026-08-04-building-a-zero-bs-engineering-journal-hugo.md
└── 2026-08-04-building-a-zero-bs-engineering-journal-hugo.fr.md
Configuration dans hugo.yaml :
defaultContentLanguage: "en"
languages:
en:
languageCode: "en-us"
weight: 1
title: "g.pineda.me"
fr:
languageCode: "fr-fr"
weight: 2
title: "g.pineda.me"
4. Pipelines de déploiement GitOps (GitHub Pages & FTP Legacy)
L’avantage d’un générateur de site statique est la liberté totale du mode de livraison. Le pipeline de déploiement peut s’adapter à n’importe quel environnement d’hébergement.
Option A : Pipeline GitHub Actions pour GitHub Pages (L’approche retenue)
À chaque git push sur la branche main, un workflow natif GitHub Actions compile le site statique et le déploie directement sur GitHub Pages en deux étapes isolées (build puis deploy) :
-> https://gohugo.io/host-and-deploy/host-on-github-pages/
name: Build and deploy
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
runs-on: ubuntu-latest
env:
# Define tool versions
DART_SASS_VERSION: 1.101.0
GO_VERSION: 1.26.4
HUGO_VERSION: 0.164.0
NODE_VERSION: 24.18.0
# Set the build time zone
TZ: Europe/Oslo
steps:
- name: Checkout
uses: actions/checkout@v7
with:
submodules: recursive
fetch-depth: 0
lfs: false
- name: Setup Pages
id: pages
uses: actions/configure-pages@v6
- name: Create a local tools directory
run: |
mkdir -p "${HOME}/.local"
- name: Install Go
if: hashFiles('go.mod') != ''
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: false
- name: Install Node.js
if: hashFiles('package-lock.json') != ''
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Dart Sass
run: |
echo "Installing Dart Sass ${DART_SASS_VERSION}..."
curl -sfL --output-dir "${{ runner.temp }}" -O "https://github.com/sass/dart-sass/releases/download/${DART_SASS_VERSION}/dart-sass-${DART_SASS_VERSION}-linux-x64.tar.gz"
tar -C "${HOME}/.local" -xf "${{ runner.temp }}/dart-sass-${DART_SASS_VERSION}-linux-x64.tar.gz"
echo "${HOME}/.local/dart-sass" >> "${GITHUB_PATH}"
- name: Install Hugo
run: |
echo "Installing Hugo ${HUGO_VERSION}..."
curl -sfL --output-dir "${{ runner.temp }}" -O "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_linux-amd64.tar.gz"
mkdir "${HOME}/.local/hugo"
tar -C "${HOME}/.local/hugo" -xf "${{ runner.temp }}/hugo_${HUGO_VERSION}_linux-amd64.tar.gz"
echo "${HOME}/.local/hugo" >> "${GITHUB_PATH}"
- name: Log tool versions
run: |
echo "Logging tool versions..."
command -v sass &> /dev/null && echo "Dart Sass: $(sass --version)" || echo "Dart Sass: not installed"
command -v go &> /dev/null && echo "Go: $(go version)" || echo "Go: not installed"
command -v hugo &> /dev/null && echo "Hugo: $(hugo version)" || echo "Hugo: not installed"
command -v node &> /dev/null && echo "Node.js: $(node --version)" || echo "Node.js: not installed"
- name: Configure Git
run: |
echo "Configuring Git..."
git config --global core.quotepath false
- name: Fetch full Git history
run: |
if [[ $(git rev-parse --is-shallow-repository) == true ]]; then
echo "Fetching full Git history..."
git fetch --unshallow
fi
- name: Initialize Git submodules
run: |
if [[ -f .gitmodules ]]; then
echo "Initializing Git submodules..."
git submodule update --init --recursive
fi
- name: Install Node.js dependencies
run: |
if [[ -f package-lock.json ]]; then
echo "Installing Node.js dependencies..."
npm ci
fi
- name: Cache restore
id: cache-restore
uses: actions/cache/restore@v6
with:
path: ${{ runner.temp }}/.cache/hugo
key: hugo-${{ github.run_id }}
restore-keys: hugo-
- name: Build
run: |
echo "Building the project..."
hugo build \
--gc \
--minify \
--baseURL "${{ steps.pages.outputs.base_url }}/" \
--cacheDir "${{ runner.temp }}/.cache/hugo"
- name: Cache save
uses: actions/cache/save@v6
with:
path: ${{ runner.temp }}/.cache/hugo
key: ${{ steps.cache-restore.outputs.cache-primary-key }}
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
include-hidden-files: false
path: ./public
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
Option B : Pipeline GitLab CI + Sync FTP (lftp) (L’approche old-school mais increvable)
Pour les serveurs hébergés chez des prestataires traditionnels (mutualisés, VPS) sans support GitOps moderne, on peut utiliser un job GitLab CI articulé autour de lftp avec un miroir incrémental (--delete) :
stages:
- build
- deploy
# Étape 1 : Compilation du site statique
hugo:
stage: build
image: jguyomard/hugo-builder
script:
- hugo
artifacts:
paths:
- public
# Étape 2 : Synchronisation delta via FTP
ftp:
stage: deploy
image: ubuntu:18.04
before_script:
- apt-get update -qy
- apt-get install -y lftp
script:
- lftp -e "open $FTP_SITE; user $FTP_USERNAME $FTP_PASSWORD; mirror -X .* -X .*/ --reverse --verbose --delete public/ /www/guillaume/; bye"
dependencies:
- hugo
L’avantage de cette approche ? On garde un workflow de rédaction 100% versionné en local, tout en poussant les modifs de manière déterministe, que ce soit vers un CDN cloud-native ou vers un serveur FTP traditionnel.
5. Et ensuite ? La route vers ouvrage-lutrin
Ce setup V1 remplit parfaitement son contrat : c’est rapide, c’est gratuit, c’est sécurisé et ça ne demande zéro maintenance.
Mais comme tout SRE qui aime construire ses propres outils, ce site servira de cobaye pour l’Acte II d’Ouvrage Systems :
- Stocker les assets statiques directement sur des buckets S3/MinIO.
- Servir le trafic via
ouvrage-lutrin, un routeur edge léger lisant ses règles de routage dynamiquement depuis des manifestes YAML.
D’ici là, GitHub Pages fait le job sans rechigner.
🛠️ Cet article a été co-conçu avec Gemini (Antigravity) selon notre Workflow de Rédaction Augmentée (voir aussi notre Manifeste du Bâtisseur).