Früher™ war Bloggen Arbeit. Ich meine: das Schreiben schon auch, klar – aber der Rest erst recht. WordPress-Login. Plugin-Update. Beitrag im Editor zusammenklicken, Bilder hochladen, in der Mediathek wiederfinden, einbetten. Tags raten. Vorschau. „Hm, sieht im Frontend anders aus." Veröffentlichen. Drei Stunden später feststellen, dass man das Ding aus Versehen als „Privat" gespeichert hat.

Heute sieht das so aus:

hugo new content/posts/2026-06-15-mein-post
# schreiben, schreiben, schreiben
git add .
git commit -m "neuer Post"
git push

Eine Minute später ist der Beitrag live. Und wenn ich will, kann er auch erst in drei Wochen erscheinen – ohne dass ich mir einen Wecker stelle.

Wie das geht? Hugo, GitLab CI, und ein Trick mit dem Pipeline-Schedule. Hier der Komplettaufbau.

Warum überhaupt?

Statische Seitengeneratoren wie Hugo drehen das klassische CMS auf den Kopf: nicht der Server rendert deine Seite bei jedem Aufruf, sondern dein Laptop (oder eine CI) baut sie einmal komplett vor – und der Webserver liefert nur noch fertige HTML-Dateien aus. Das hat ein paar sehr praktische Folgen:

  • Schnell, weil HTML-Dateien direkt vom Disk gehen – kein PHP, keine Datenbank, kein Plugin-Stack
  • Sicher, weil es keinen Login, keinen Admin-Bereich und kein Plugin gibt, das gehackt werden könnte
  • Versionierbar, weil dein gesamter Blog aus Textdateien besteht – jede Änderung ist ein Commit
  • Portabel, weil das Repo überall läuft: dein Laptop, ein anderer Server, gitlab.com, GitHub

Der entscheidende Komfort-Gewinn kommt aber erst durch die Automatisierung dahinter. Genau die machen wir jetzt.

Was du brauchst

  • Einen Hugo-Blog in einem Git-Repo (bei mir GitLab, geht aber 1:1 auch mit GitHub Actions oder Gitea)
  • Einen GitLab Runner, der auf das Deployment-Ziel zugreifen kann (bei mir ein Shell-Runner auf dem Server, der direkt in den Webroot schreibt)
  • Eine kleine .gitlab-ci.yml im Repo

Wenn dein Runner z.B. in einer Container-Umgebung läuft und per rsync auf den Webserver pusht, ist das genauso möglich – nur das --destination wird anders aussehen.

Vorbereitung: GitLab Runner installieren

Bevor wir die Pipeline schreiben können, brauchen wir einen Runner. Das ist das Programm, das auf einer Maschine läuft, sich bei GitLab meldet und sagt: „Hi, ich nehme Jobs an." Sobald jemand was pusht, schickt GitLab dem Runner den Job-Auftrag, der Runner führt ihn aus und meldet das Ergebnis zurück. So einfach.

Bei mir läuft der Runner als Shell-Runner direkt auf dem Server, auf dem auch nginx steht. Vorteil: er kann beim Build direkt in den Webroot schreiben, ohne dass dazwischen noch ein Transfer nötig wäre. Wer’s lieber isoliert hat, nimmt einen Docker-Executor – dann läuft jeder Job in einem frischen Container und der Runner-Host bleibt sauber.

Installation auf Debian/Ubuntu

Wenn dein Server Debian-basiert ist, geht’s so:

# Paket-Repo hinzufügen
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash

# Installieren
sudo apt install gitlab-runner

Das Paket legt einen System-User gitlab-runner an, in dessen Home die Jobs laufen. Wichtig zu wissen: alles, was die Pipeline tut, tut sie unter diesem User. Berechtigungen also passend setzen – mein gitlab-runner darf z.B. in /home/webs/webc123/html schreiben, weil ich ihn entsprechend zur passenden Gruppe hinzugefügt habe.

Für RPM-basierte Distributionen, macOS oder Windows gibt’s eigene Pakete bzw. Installer in der offiziellen Doku. Konzept bleibt dasselbe.

Beim GitLab anmelden (Registrierung)

Jetzt muss der Runner GitLab kennenlernen. In deinem Projekt:

Settings → CI/CD → Runners → Expand → New project runner

Du bekommst dort ein Registrierungs-Token. Damit auf dem Server:

sudo gitlab-runner register \
  --url https://gitlab.example.com \
  --token glrt-<dein-token>

Das Tool fragt dann ein paar Sachen ab:

  • Executor: shell für meinen Aufbau (direkter Zugriff aufs Dateisystem) oder docker (isoliert pro Job)
  • Tags: hugo – das matched mit dem tags: [hugo] Block in der Pipeline-Datei. So holt sich der Runner nur die Jobs, die für ihn gedacht sind. Praktisch, wenn auf demselben GitLab später noch andere Projekte mit eigenen Runnern dazukommen

Wenn du alles bestätigt hast, taucht der Runner in der GitLab-Oberfläche als „aktiv" auf (grüner Kreis) und nimmt ab sofort Jobs entgegen.

Kurze Sicherheitsanmerkung

Ein Runner führt das aus, was in deiner .gitlab-ci.yml steht – mit allem, was der User gitlab-runner darf. Heißt: pack ihn nicht auf eine Maschine, auf der wichtige andere Dinge laufen, und gib ihm nur die Rechte, die er wirklich braucht. Für reine Build-Aufgaben reicht meistens ein eingeschränkter User mit Schreibrechten im Webroot – und sonst nichts.

Wenn du den Runner mit docker-Executor betreibst, ist das Risiko geringer, weil jeder Job in einem frischen Container startet und nach dem Lauf weggeworfen wird. Für den Privatblog ist Shell-Executor trotzdem völlig ausreichend, solange der Server nicht gleichzeitig deine Steuersoftware hostet.

Die Pipeline-Datei

Im Root deines Repos kommt eine .gitlab-ci.yml. Hier meine, gekürzt und annotiert:

stages:
  - build

variables:
  PUBLIC_DIR: "/home/web/webc123/html"      # Webroot auf dem Server
  BASE_URL: "https://blog.renesasse.de/"
  HUGO_VERSION: "0.146.0"                  # PaperMod-Mindestversion

build:
  stage: build
  tags:
    - hugo   # nur Runner mit diesem Tag bekommen den Job
  before_script:
    - export PATH="$HOME/.local/bin:$PATH"
    - |
      if command -v hugo >/dev/null && hugo version | grep -q "v${HUGO_VERSION}"; then
        echo "Hugo ${HUGO_VERSION} bereits installiert – skip."
      else
        echo "Hugo ${HUGO_VERSION} fehlt – lade herunter..."
        mkdir -p "$HOME/.local/bin"
        curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz" \
          | tar -xz -C "$HOME/.local/bin" hugo
      fi
  script:
    - hugo --minify --baseURL "$BASE_URL" --destination "$PUBLIC_DIR"
  only:
    - main

Was hier passiert, Stück für Stück:

  1. HUGO_VERSION als Variable. Damit pinne ich die Build-Version. Das System-Hugo auf meinem Runner ist nämlich uralt (0.111) und kann mein Theme nicht bauen. Heute pinned ich 0.146, morgen vielleicht 0.157 – ein Wert ändern, fertig.

  2. before_script: Hugo-Binary einmalig herunterladen. Beim allerersten Run lädt der Job das Binary nach $HOME/.local/bin/hugo. Bei allen folgenden Runs sieht er: „passt schon, gleiche Version" – und überspringt den Download. Das spart bei jedem Build ein paar Sekunden und ein paar Megabyte Bandbreite.

  3. script: der eigentliche Build. Hugo erzeugt das komplette HTML, kopiert es nach $PUBLIC_DIR. Das ist auf dem Server der Webroot von nginx/Apache. Die Site ist damit deployed.

  4. only: main. Nur Pushes auf den Main-Branch lösen den Build aus. Feature-Branches kannst du nutzen, um Posts auf der Seite vorzubereiten, ohne dass sie sofort live gehen.

Allein das ist schon ein riesiger Komfort-Gewinn: jeder git push origin main ist gleichzeitig „Publish". Kein FTP, kein WordPress-Admin, kein „bitte nicht jetzt abstürzen".

Der zweite Trick: Posts mit Zukunfts-Datum

Jetzt zum Teil, den ich besonders mag. In Hugos Config (bei mir hugo.toml) steht:

[params]
  buildFuture = false

Das heißt: Posts, deren date: in der Zukunft liegt, werden beim Build komplett ignoriert. Setz im Frontmatter eines Posts:

---
title: "Mein vorbereiteter Post"
date: 2026-08-01
draft: false
---

…dann passiert beim Build heute schlicht nichts – Hugo überspringt den Beitrag. Am 1. August 2026 hingegen ist der Post plötzlich Teil des Builds und geht live.

Klingt magisch, hat aber einen Haken. Hugo prüft das Datum nur zum Build-Zeitpunkt. Es gibt keinen Cronjob in Hugo selbst, der nachts mal nachschaut, ob ein Post heute fällig wäre.

Und die Pipeline läuft per only: main nur bei einem git push. Wenn ich also zwischen heute und dem 1. August nichts pushe, baut auch nichts. Der Post bleibt unsichtbar. Toll.

Hier kommt der Scheduler ins Spiel.

Pipeline Schedule – die eigentliche Magie

GitLab bringt einen Pipeline-Scheduler mit. Du sagst ihm: „lauf die Pipeline da-und-da-zur-Zeit von selbst" – und genau das tut er.

In GitLab im Projekt → linke Sidebar → BuildPipeline schedulesNew schedule:

Feld Wert
Description Täglicher Rebuild für Future-Posts
Interval Pattern Custom, Cron: 0 5,17 * * *
Cron Timezone [UTC +1] Berlin
Target branch main
Activated

Das Cron-Muster bedeutet: Minute 0, Stunden 5 und 17 – also zweimal täglich, morgens und nachmittags. Beispiele für andere Frequenzen:

  • 0 */6 * * * – alle 6 Stunden
  • 15 4 * * * – jeden Tag um 4:15 Uhr
  • 0 6 * * 1-5 – nur werktags, morgens um 6

Speichern, fertig. Und ab dem Moment bist du raus aus dem Spiel.

Was ab jetzt passiert

Du legst einen Post mit date: 2026-08-01 ins Repo. Heute, in zwei Wochen, in einem Monat – egal wann. Du pushst. Die Pipeline baut – der Post ist Zukunft, also nicht im Build. Alles ruhig.

Am 1. August um 5 Uhr morgens läuft die Pipeline durch den Scheduler an. Hugo schaut: Aha, dieser Post ist jetzt gültig. Wird ins Build aufgenommen, landet im Webroot. Um 5:00:30 ist er online.

Was du in der Zwischenzeit machst: gepennt. Oder im Wald gesessen. Oder im Flugzeug. Oder einfach vergessen, dass es den Post überhaupt gibt.

Das ist der Punkt, an dem das Setup vom „praktischen Workflow" zum „kleinen Wunder" wird. Du kannst drei Beiträge auf einmal schreiben, datieren, vergessen – und sie kommen an unterschiedlichen Tagen, ohne dass du je wieder hingucken musst.

Plan B: kein eigener Server, kein eigener Runner

Bis hier habe ich beschrieben, wie es bei mir läuft – mit einem GitLab Runner, der auf einer Maschine sitzt, zu der ich Shell-Zugang habe. Damit kann ich Hugo installieren, Binaries cachen, in den Webroot kopieren. Schön, wenn man das hat.

Aber: viele haben das nicht. Du hast einen Shared-Hosting-Vertrag bei einem Anbieter, der dir nur SFTP gibt. Oder dein eigentlicher Webspace ist eine Plattform wie 1&1, Strato, all-inkl – Dateien hochladen darfst du, aber „mal eben einen Runner installieren" geht nicht. Oder du hast schlicht keinen Server und willst auch keinen.

Geht trotzdem. Drei Wege, je nach Ausgangslage:

Variante 1: GitLab hostet die Seite gleich mit (GitLab Pages)

GitLab kann fertig gebaute Seiten selbst ausliefern – die Pipeline läuft auf einem von GitLab gestellten Shared Runner, du brauchst nichts Eigenes. Im einfachsten Fall sieht die .gitlab-ci.yml so aus:

pages:
  stage: deploy
  image: registry.gitlab.com/pages/hugo:latest
  script:
    - hugo --minify
  artifacts:
    paths:
      - public
  only:
    - main

Was hier passiert:

  • Das Image bringt Hugo schon mit – kein Download nötig
  • hugo --minify baut die Seite nach public/
  • GitLab nimmt das public/-Verzeichnis als „Artifact" und veröffentlicht es als statische Webseite
  • Erreichbar unter https://<deinuser>.gitlab.io/<reponame> – eigene Domain mit eigenem SSL geht aber auch

Schedule funktioniert für GitLab Pages genauso wie oben beschrieben. Future-Posts inklusive.

Für mich persönlich nicht passend, weil ich den Build und das Hosting bewusst getrennt halte – aber für viele Anwendungsfälle ist das die einfachste Variante überhaupt: Repo anlegen, eine Handvoll Zeilen YAML rein, online.

Variante 2: andere Hoster mit Git-Integration

Wenn du den Build-und-Hosting-aus-einer-Hand-Ansatz magst, aber GitLab Pages aus irgendeinem Grund nicht willst:

  • GitHub Pages mit GitHub Actions – gleicher Workflow, andere Plattform
  • Cloudflare Pages – verbindet sich direkt mit deinem Git-Repo, baut bei jedem Push, deployed auf das Cloudflare-CDN. Custom-Domain inklusive
  • Netlify – einer der ersten in diesem Markt, sehr ausgereift
  • Vercel – ursprünglich für Next.js und Co., baut Hugo aber genauso

Alle vier brauchen nur einen Git-Repo. Du verbindest dein Repo mit dem Dienst, der checkt bei jedem Commit aus, baut die Seite und stellt sie auf seine CDN-Infrastruktur. Domain umstellen, fertig. Für den klassischen Privatblog ohne eigenen Server praktisch unschlagbar.

Wichtig fürs Schedule-Thema: auch bei diesen Diensten lässt sich ein Cron-artiger Trigger einrichten – entweder direkt im Dienst (Cloudflare Pages „Deploy Hooks" + ein externer Cron-Service) oder über die GitLab-/GitHub-Schedules, die dann einen leeren Re-Build anstoßen. Etwas weniger elegant als der hier gezeigte Weg, aber funktioniert.

Variante 3: du hast nur SFTP/FTP zum Webspace

Klassisches Szenario: dein Hoster gibt dir nur Datei-Zugriff. Kein Shell, kein Cron, kein Eigenrunner. Trotzdem geht’s – du nimmst einen GitLab-Shared-Runner als Build-Maschine und schiebst das Ergebnis per SFTP rüber:

deploy:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache lftp hugo
  script:
    - hugo --minify
    - |
      lftp -c "
        open sftp://$SFTP_HOST;
        user $SFTP_USER $SFTP_PASS;
        mirror -R --delete --verbose public/ $SFTP_REMOTE_PATH;
      "
  only:
    - main

Die Credentials ($SFTP_HOST, $SFTP_USER, $SFTP_PASS) legst du in GitLab unter Settings → CI/CD → Variables als „Masked" und idealerweise „Protected" an. Sie tauchen nirgendwo im Repo auf und werden in den Job-Logs automatisch ersetzt.

Funktioniert auch mit gewöhnlichem FTP – aber wenn dein Hoster SFTP anbietet, nimm SFTP. Die Variante hat den schönen Nebeneffekt, dass der Schedule-Trick trotzdem funktioniert: alle 12 Stunden checkt der Runner aus, baut neu, und ein neuer Future-Post landet im richtigen Moment auf deinem Webspace.

Was alle drei Varianten gemeinsam haben

In allen Fällen bleibt das Gefühl gleich: du schreibst lokal, pushst, vergisst. Der Unterschied ist nur, wer den Build macht und wer die Dateien serviert. Workflow und Schedule-Trick funktionieren überall.

Drei Sachen, die in der Praxis Gold wert sind

Versionskontrolle. Jeder Post ist ein Commit. git log zeigt dir, was du wann geschrieben hast. git diff zeigt dir, was sich geändert hat. git revert rollt einen Post zurück, wenn er dir nach einem Tag doch nicht mehr gefällt. Das geht in keinem CMS so sauber.

Backup geschieht beiläufig. Dein Repo liegt auf deinem Laptop. Beim push zusätzlich auf dem GitLab-Server. Wenn du willst, kannst du auch noch auf gitlab.com spiegeln. Drei Kopien, ohne dass du je „Backup machen" denkst.

Lokale Vorschau ist 1:1 wie live. hugo server öffnet localhost:1313. Was du dort siehst, sieht später im Browser deiner Leser identisch aus – Theme, CSS, alles. Kein „auf Production sieht’s anders aus", kein Plugin, das in der Test-Umgebung nicht installiert ist. Das, was lokal funktioniert, funktioniert auch live.

Ein typischer Workflow bei mir

# Neuer Post anlegen – Hugo nimmt das passende Archetype
hugo new content/posts/2026-06-15-warum-yaml-mich-aergert

# Editor öffnen, Markdown schreiben, Bilder ins Bundle legen

# Lokal anschauen
hugo server

# Wenn's gut aussieht: ab damit
git add .
git commit -m "neuer Post: yaml"
git push

Wenn der Post erst nächste Woche raussoll: date: auf den Wunschtag setzen, draft: false, pushen. Der Scheduler kümmert sich. Punkt.

Pointe

Ich kann mich nicht mehr erinnern, wann ich zuletzt in einem WordPress-Admin eingeloggt war. Und ehrlich gesagt: ich vermisse genau nichts daran. Außer vielleicht den dramatischen Update-Reminder oben in der Leiste. Den braucht’s halt manchmal als kleinen Adrenalinkick.