Continuous Deployment für Hugo
Inhalt
Gerade habe ich meine Website von Hugo auf Jekyll umgezogen. Nun will ich sie automatisiert bauen und Änderungen ausrollen, sobald ich einen Artikel fertiggestellt und ihn per git push und anschließendem Merge in main auf meine Gitea-Instanz geschoben habe.
Die Befehlskette#
Ich habe einen VPS gemietet, auf dem ich alle Anwendungen in Docker laufen lasse. Die Aufgabe ist nun, einen workflow in Gitea zu bauen (“Actions”), welcher das Update im Repository auscheckt, es hugo zur Verfügung stellt, dort die HTML-Seiten erzeugen lässt, und sie anschließend so auf der Festplatte ablegt, dass mein Webserver die Dateien einlesen und ausliefern kann.
Erfahrungswerte#
Das alles hatte ich für meine Website in Jekyll bauen und ausrollen können, daher kann ich auf ein paar Erfahrungswerte zurückgreifen.
Üblicherweise nutzt man dafür standardisierte Aktionen. Ich vereinfache mal stark und liste nur die Hauptdarsteller auf, ohne in fiese Details wie Rechtevergabe (darf Hugo in diesen Ordner schreiben? Hat der hugo-user genügend Rechte?) tiefer einzugehen:
- checkout stellt einen gewünschten Stand des Quellrepository bereit
- actions-hugo installiert hugo in der virtuellen Maschine der Action, sodass die Website gebaut werden kann
- upload-artifact lädt die von hugo erstellte Seite auf den Webserver hoch
Da ich jedoch auf ein- und derselben Maschine in Docker unterwegs bin, kann ich mir das aufwändige Einwählen auf dem Webserver “von außen” sparen. Daher gehe ich einen anderen Weg.
Docker shared Volumes#
Ich kann meiner Caddy-Anwendung direkt die gebauten Dateien zur Verfügung stellen, wenn ich das von ihr gelesene Volume bis in die Action weiterreiche.
Aufgepasst: Action volume != Hugo volume#
Doch das funktioniert mit actions-hugo nicht: Sie ist selbst kein Docker container, kann also keine Volumes einbinden. Sie baut zwar die Webseite fertig; sobald die Aktion jedoch durchgelaufen ist, reißt sie die Artefakte mit ins Nirvana.
Ich benötige also eine hugo-Runtime, die selbst in einem Docker-Container läuft - ganz nach dem DinD-Ansatz. Diesem kann ich dann das von Caddy verwendete Volume mit Schreibzugriff durchreichen.
Der Workflow#
Doch welchen Docker-Container soll ich nehmen?
Man stelle sich nun mehrere Stunden der Recherche und viele Fehlversuche beim Einrichten des Workflows auf meinem Server vor.
Folgendes ergab sich aus meinen Untersuchungen:
checkout@v4benötigt node.js, und zwar mindestens Version node:18. Der Docker-Container (und das darunterliegende Betriebssystem des Runners!) müssen es installiert haben bzw. unterstützen.- Die Action benötigt
git. Auch dies muss der Docker-container idealerweise bereits mitbringen. - Hugo braucht
go. Klingt soweit logisch.
Runner-Betriebssystem anpassen#
Mein Runner aus dem Jahr 2023 ist inzwischen in die Tage gekommen. Er basiert auf node:16-bullseye, eine Debian-basierte VM. Ich probiere etwas mit Alpine herum, doch diese Installation ist zu minimal für meine Zwecke und lässt mich mit vielen manuellen Anpassungen zurück. Schließlich muss der Runner noch andere Workflows ausführen als diesen hier. Ich entscheide mich letztendlich, bei Debian zu bleiben und nur die Node-Version hochzuziehen. Die Umsetzung in der Runner-Konfiguration sieht dann wie folgt aus:
# /gitea/runner/config.yml
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: ["macos-arm64:host", "ubuntu-latest:docker://node:16-bullseye", "ubuntu-22.04:docker://node:16-bullseye"]
# If it's empty when registering, it will ask for inputting labels.
# If it's empty when execute `deamon`, will use labels in `.runner` file.
labels: [ubuntu-latest:docker://node:20-bullseye]
Anschließend muss der Runner-Container neu gestartet werden. Idealerweise bootet man Gitea gleich mit durch, dann funktioniert das Registrieren des Runners auch zuverlässig.
Das Workflow-Script schreiben#
Um die oben beschriebenen Abhängigkeiten abzudecken entscheide ich mich für einen “batteries included”-Ansatz. So kann ich die Bausteine einfach ohne manuelle Anpassungen zusammenstecken und vertraue auf langfristige Wartung und Pflege der Container, da sie wirklich sehr häufig verwendet werden (Größenordnung mehrere Millionen Pulls). Diese Webseite half mir sehr bei der Auswahl.
Schließlich erstelle ich meinen Workflow basierend auf dem Container von Hugomods.
# /.gitea/workflows/build-deploy-with-hugo.yml
name: Deploy Hugo site
run-name: ${{ gitea.actor }} builds Hugo site
on:
push:
branches:
- main
jobs:
# Build job
build:
runs-on: ubuntu-latest # this is the "label" the runner will use and map to docker target OS
container:
image: hugomods/hugo:latest
volumes:
- /tmp/blog-artifacts:/tmp/blog-artifacts
steps:
- name: --- CHECKOUT ---
uses: actions/checkout@v4
with:
submodules: recursive # Fetches Hugo themes
fetch-depth: 0
- name: --- BUILD WITH HUGO --- # chown -R hugo /tmp/blog-artifacts
run: |
hugo \
--minify \
--cleanDestinationDir \
--destination /tmp/blog-artifacts
Zielordner bereinigen#
Ich habe mich im obigen Beispiel für ein “direktes Deployment” entschieden, ohne Netz und doppelten Boden, und ohne, dass ich die Artefakte herumkopiere oder gar den Server neu starte. Begründung: Der Hugo-Bauprozess benötigt für Meine Seite weniger als 2000ms. Nur in diesem Fenster kann Caddy theoretisch eine “korrupte” Datenbank sehen und ausliefern, also nur während die Platte vom Hugo-Build-Workflow noch beschrieben wird.
Um Inkonsistenzen trotzdem weitgehend zu vermeiden, gebe ich Hugo die Option --cleanDestinationDir mit. Damit ist das Zielverzeichnis stets sauber und Caddy wird im Zweifel keine defekte Seite anzeigen, sondern höchstens eine 404-Fehlermeldung erzeugen.
Submodules#
Wie in meiner Projektdokumentation geschrieben, kann man in Hugo zum Laden von Themes sogenannte submodules verwenden. Diese werden in das Projekt eingeklickt und können, wenn korrekt verlinkt, dann auch im Workflow verwendet werden:
with:
submodules: recursive # Fetches Hugo themes
fetch-depth: 0
Ist alles richtig eingestellt, sollte es nicht zu folgender Fehlermeldung kommen:
# workflow action's error message due to incoherent submodules' commit references
# actions/checkout@v4:
Fetching submodules
/usr/bin/git submodule sync
/usr/bin/git -c protocol.version=2 submodule update --init --force
Submodule 'themes/terminal' (https://github.com/panr/hugo-theme-terminal.git) registered for path 'themes/terminal'
Cloning into '/workspace/schallbert/blog-hugo/themes/terminal'...
fatal: remote error: upload-pack: not our ref 719505fc89332baa69bffb90cee708ff124dd143
Fetched in submodule path 'themes/terminal', but it did not contain 719505fc89332baa69bffb90cee708ff124dd143. Direct fetching of that commit failed.
Error: The process '/usr/bin/git' failed with exit code 1
Error: Process completed with exit code 1.
Voraussetzung ist, dass man die Submodule auch lokal richtig geladen hat:
schallbert@machine:~ git submodule add -f https://github.com/<my/hugo-theme>.git themes/my-hugo-theme
schallbert@machine:~ git submodule update --init --recursive
Die Statusabfrage darf keinen leeren String zurückgeben, sondern muss einen Commit hash beinhalten. Beispiel:
schallbert@machine:~ git submodule status
44d9a1890d228745ffc300b37a7d73e940ef9fa9 themes/terminal (v4.2.5)
Weiteres Zeugnis ist die .gitmodules-Datei, welche eine Referenz auf das Theme beinhalten muss:
schallbert@machine:➜/blog git:(main) ✗ nano .gitmodules
[submodule "themes/terminal"]
path = themes/terminal
url = https://github.com/panr/hugo-theme-terminal.git
Funktioniert das nicht, so sind die Submodule nochmal komplett zu entfernen und antschließend wie oben beschrieben neu zu initialisieren:
git submodule deinit -f themes/<my-theme>
git rm -r --cached themes/<my-theme>
rm -rf .git/modules/themes/<my-theme>
Typische Probleme: Hugo-Versionen und Deprecations#
Nun verlief die checkout-Aktion erfolgreich. Doch der Build mit Hugo schlägt fehl:
error calling partial: "/workspace/schallbert/blog-hugo/layouts/_partials/head.html:37:40": execute of template failed: template: _partials/head.html:37:40: executing "_partials/head.html" at <$.Page.Language.Locale>: can't evaluate field Locale in type *langs.Language
Error: Process completed with exit code 1.
Lokal auf meiner Maschine baut Hugo erfolgreich, gibt aber ein paar Warnungen heraus:
Änderungen der Language-identifier und -Auswertung ab v0.158#
schallbert@machine:➜/blog git:(main) ✗ hugo build --logLevel info
WARN deprecated: site.Language.Locale was deprecated in Hugo v0.158.0 and will be removed in a future release. Use .Page.Language.Locale instead.
WARN deprecated: .Language.Lang was deprecated in Hugo v0.158.0 and will be removed in a future release.
Man könnte denken, dass sich diese Fehler leicht beheben ließen. Leider können sie sich prima verstecken:
- in Submodules, die man als third-party-content lädt (z.B. in Themes). Eine projektweite Suche in der IDE hat Submodules üblicherweise ausgeblendet und liefert keine Treffer.
- als Variablen im eigenen Code, die nur indirekt auf die alten Parameter zugreifen: Ich benutze bis dato
.Page.Site.Home.AllTranslations, um herauszufinden, ob ich mich auf einer gegebenen Seite in der “Hauptsprache” aufhalte. Allein schon das Scoping ist widersinnig und wird meiner Meinung nach zu Recht entfernt. Das, was ich tun will, kann nun viel einfacher erreicht werden mit.Page.Language.IsDefault
Submodules auf Stand bringen#
Bei den Submodulen sehe ich zwei Lösungsansätze:
- Die Maintainer anschreiben (bzw. ein Issue aufmachen), dass ihr Code gerade veraltet und sie darum bitten, ihre Themes auf Stand zu bringen.
- Die betroffenen Dateien aus dem Submodule herauskopieren und in die eigene Ordnerstruktur integrieren. So würde das in meinem Falle betroffene
language-menu.htmlunter/layouts/partials/language-menu.htmleine neue Heimat finden. Dort behebe ich dann die Probleme selbst.
Eigene Lösungen programmieren#
Macht die Fehlermeldung keine Lösung offensichtlich, so kann man sich gut an der Hugo Community orientieren. Für die Deprecatoins in v0.158 gibt es beispielsweise eine ausführliche Dokumentation.
Versionen abgleichen#

Hmm. Wie das Bild zeigt, verwendet mein Container das Paket hugo extended von hugomods, welches die Version v0.154.5 bereitstellt; ich hatte einen stabilen release für die Action ausgewählt. Vergleichen wir mal mit meiner lokalen Version, die inzwischen ohne auch nur eine Warnung brav baut:
➜ blog git:(main) ✗ hugo version
hugo v0.162.1+extended linux/amd64
Der Klassiker: Es gibt eine Änderung ab v0.158, die die interne API verändert. Ältere Versionen kennen die neuen Felder nicht und meckern. Neuere Versionen hingegen kreiden die Verwendung der alten Felder an.
Ich glaube, diese Probleme lassen sich nur dann zuverlässig vermeiden, wenn die Pipeline für lokal und remote identisch ist. Sprich: Auf dem Lokalen Rechner eine virtuelle Maschine mit dem CI-Betriebssystem booten und alles in Docker wegkapseln. Macht bestimmt Sinn, wenn man in einer größeren Organisation arbeitet und mehrere User vor diesem Problem stehen.
Stattdessen wechsle ich zur Behebung im Remote Container auf die Version image: hugomods/hugo:latest und behebe damit den Fehler. Jedoch habe ich noch immer unterschiedliche Versionen lokal und in der CD-Pipeline. Für mein Betriebssystem gibt es die neueste schlicht noch nicht und auch der Docker Release hängt zeitlich etwas hinter den Releases des Originals.

Die Bau- und Releasezeiten sind fantastisch, oder? Eine Änderung an der Website in 12 Sekunden online zu haben, inkl. Container hochziehen etc. - das gab es bei mir jedenfalls noch nie. Um die 20 Sekunden benötigt der Durchlauf, wenn er die Container refs erst neu holen und herunterladen muss, z.B. bei Updates der Registry von latest.
Einen detaillierten Benchmarkd zwischen Hugo und Jekyll bietet der verlinkte Artikel.