↓Zum Hauptinhalt springen

Privater Quelltext, öffentliche Website: Einen Hugo-Blog mit GitHub Actions veröffentlichen

·8 min
Dieser deutschsprachige Beitrag ist eine KI-unterstützte Übersetzung des englischen Originals.

Ich wollte diesen Blog veröffentlichen, ohne einen Server zu betreiben. Gleichzeitig wollte ich das Quellrepository nicht öffentlich machen. Das Repository enthält mehr als die fertige Website. Dazu gehören die Historie des Blogs, unfertige Artikel, die Konfiguration und möglicherweise persönliche Notizen, die ich später ergänze.

Hugo schafft bereits eine nützliche Grenze für dieses Problem. Der Quelltext bleibt an einem Ort, während das Verzeichnis public/ die statischen Dateien enthält, die Besucher benötigen. Bei meinem Deployment bleibt der Quelltext in einem privaten Repository. Nur das generierte Verzeichnis wird über ein zweites, öffentliches Repository veröffentlicht.

Das Ergebnis ist eine öffentliche Website mit privatem Quellmaterial und ohne zusätzlichen Server, den ich betreiben muss. Die Website selbst wird dadurch nicht privat. Alles, was Hugo in die generierte Ausgabe aufnimmt, bleibt öffentlich.

Dieser Beitrag erklärt, wie sich dieses Setup mit GitHub Actions, einem SSH-Deploy-Key und GitHub Pages nachbauen lässt.

Das Setup mit zwei Repositories #

Die Repositories haben unterschiedliche Aufgaben:

RepositorySichtbarkeitInhalt
blogPrivatHugo-Inhalte, Konfiguration, Skripte und der GitHub-Actions-Workflow
gh-pages-blogÖffentlichGenerierte Dateien aus Hugos public/-Verzeichnis

Das Deployment folgt einem festen Weg vom Quelltext bis zur Website:

flowchart LR source[Privates Blog-Repository] -->|Push auf main| actions[GitHub Actions] actions -->|bauen und prüfen| output[Hugo-public-Verzeichnis] actions -->|SSH-Push| repository[Öffentliches Deployment-Repository] repository -->|main aus dem Wurzelverzeichnis veröffentlichen| pages[GitHub Pages] pages --> domain[constantinsblog.eu]

Ich behandle das öffentliche Repository als Artefakt-Repository. Dort schreibe ich keine Beiträge und bearbeite kein generiertes HTML. Falls das Repository verloren ginge, könnte ich seinen Inhalt wiederherstellen, indem ich das private Quellrepository erneut baue.

Für diesen Ansatz werden folgende Dinge benötigt:

  1. Ein privates Repository mit der Hugo-Website
  2. Ein öffentliches Repository für die generierte Website
  3. Ein main-Branch im öffentlichen Repository

Das öffentliche Repository kann mit einem ersten Commit beginnen. Beim ersten Deployment wird sein Inhalt durch die generierte Website ersetzt.

Dem Workflow Zugriff auf das öffentliche Repository geben #

Der Workflow läuft im privaten Repository, benötigt aber Schreibzugriff auf das öffentliche Repository. Dafür verwende ich einen eigenen SSH-Deploy-Key statt eines persönlichen Schlüssels oder eines Tokens mit Zugriff auf mehrere Repositories.

Das neue Schlüsselpaar wird lokal erzeugt:

ssh-keygen -t ed25519 -C "blog-pages-deployment" -f ./blog-pages-deploy-key

Dadurch entstehen ein privater Schlüssel namens blog-pages-deploy-key und ein öffentlicher Schlüssel namens blog-pages-deploy-key.pub.

Im öffentlichen Deployment-Repository öffne ich Settings → Deploy keys, füge den öffentlichen Schlüssel hinzu und aktiviere Allow write access. Der Schlüssel ist dadurch auf dieses eine Repository beschränkt.

Im privaten Quellrepository öffne ich Settings → Secrets and variables → Actions und hinterlege den privaten Schlüssel als Repository-Secret mit dem Namen GH_PAGES_DEPLOY_KEY.

Der private Schlüssel darf niemals in eines der Repositories eingecheckt werden. Nachdem beide Teile bei GitHub hinterlegt wurden, können die lokalen Schlüsseldateien entfernt werden, falls sie nicht mehr benötigt werden. Sollte der private Schlüssel offengelegt werden, muss der Deploy-Key aus dem öffentlichen Repository entfernt und ersetzt werden.

Hugo mit GitHub Actions bauen #

Der Workflow startet nach einem Push auf main. Zusätzlich erlaube ich manuelle Ausführungen, was beim Testen von Deployment-Änderungen hilfreich ist:

name: Build and publish Hugo site

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

Die explizit gesetzte Berechtigung ist bewusst eng gefasst. Der Workflow muss das private Quellrepository nur lesen. Der SSH-Deploy-Key stellt unabhängig davon den erforderlichen Schreibzugriff auf das öffentliche Repository bereit.

Die ersten Schritte checken den Quelltext aus und installieren eine festgelegte Hugo-Version:

jobs:
  publish:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source
        uses: actions/checkout@v5
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Install Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.165.0'
          extended: true

Die festgelegte Version vermeidet eine Abhängigkeit von der Hugo-Version, die zufällig auf dem Runner installiert ist. Ich aktiviere Hugo Extended, weil das Theme diese Variante benötigt.

Der Build selbst ist streng konfiguriert:

      - name: Build site
        run: >-
          hugo --gc --cleanDestinationDir --minify
          --printI18nWarnings --printPathWarnings --panicOnWarning

--cleanDestinationDir entfernt Dateien aus vorherigen Builds. Mit --panicOnWarning verhindert bereits eine Hugo-Warnung die Veröffentlichung. Das Ergebnis landet in public/, das im privaten Quellrepository weiterhin ignoriert wird.

Die generierte Website prüfen #

Ein erfolgreich ausgeführter Hugo-Befehl garantiert nicht, dass jeder generierte interne Link auf eine vorhandene Datei zeigt. Deshalb habe ich eine kleine lokale Prüfung als weitere Freigabestufe ergänzt:

      - name: Check generated internal links
        run: python3 scripts/check-internal-links.py public

Das Skript verarbeitet das generierte HTML und prüft Links und Ressourcen derselben Website aus href und src gegen den Inhalt von public/. Es ruft keine externen Websites auf und versucht nicht, jede mögliche Browser-Ressource zu validieren. Sein Zweck ist enger gefasst. Es soll fehlende interne Seiten und Dateien vor dem Deployment finden.

Die Prüfung verwendet ausschließlich die Python-Standardbibliothek und erfordert deshalb keinen weiteren Installationsschritt. Sie ist an dieser Stelle besonders nützlich, weil sie Hugos endgültige Ausgabe untersucht, nachdem Inhaltspfade, Sprachpräfixe und Ausgabeformate aufgelöst wurden.

Ein einfacheres Setup kann diese Prüfung weglassen. Die Deployment-Architektur hängt nicht davon ab. Ich stoppe den Workflow jedoch lieber, als eine funktionierende Website bewusst durch eine generierte Ausgabe zu ersetzen, die bereits diese grundlegende Prüfung nicht besteht.

Die generierten Dateien in das öffentliche Repository pushen #

Nach erfolgreichem Build und bestandener Prüfung lädt der Workflow den privaten Deploy-Key:

      - name: Configure Pages deploy key
        uses: webfactory/ssh-agent@v0.10.0
        with:
          ssh-private-key: ${{ secrets.GH_PAGES_DEPLOY_KEY }}

Anschließend kann er das öffentliche Repository über SSH klonen:

      - name: Checkout Pages repository
        run: |
          git clone --branch main \
            git@github.com:cponfick/gh-pages-blog.git \
            "$GITHUB_WORKSPACE/gh-pages-blog"

Mein vollständiger Workflow behandelt auch ein Repository ohne Branches. Der normale Deployment-Pfad bleibt jedoch einfacher, wenn der erste main-Branch bereits beim Einrichten des öffentlichen Repositories erstellt wird.

Die generierte Ausgabe ersetzt danach den Inhalt des geklonten Repositories:

      - name: Publish generated files
        run: |
          rsync --archive --delete \
            --exclude='.git/' \
            public/ gh-pages-blog/

          git -C gh-pages-blog config user.name "github-actions[bot]"
          git -C gh-pages-blog config user.email \
            "41898282+github-actions[bot]@users.noreply.github.com"
          git -C gh-pages-blog add --all

          if git -C gh-pages-blog diff --cached --quiet
          then
            echo "No changes to publish"
            exit 0
          fi

          git -C gh-pages-blog commit -m "Publish blog"
          git -C gh-pages-blog push origin main

Die wichtige Option ist rsync --delete. Wenn ich eine Seite oder Datei aus dem Hugo-Quelltext entferne, muss auch die zugehörige generierte Datei aus dem öffentlichen Repository verschwinden. Ohne --delete könnten alte Dateien öffentlich erreichbar bleiben, obwohl sie nicht mehr zur aktuellen Website gehören.

Das .git-Verzeichnis wird ausgeschlossen, da es zum Deployment-Repository und nicht zur generierten Website gehört. Der Workflow vermeidet außerdem leere Commits. Dadurch steht jeder Commit im öffentlichen Repository für eine tatsächliche Änderung der veröffentlichten Ausgabe.

GitHub Pages konfigurieren #

Im öffentlichen Repository öffne ich Settings → Pages → Build and deployment und konfiguriere Folgendes:

  • Source: Deploy from a branch
  • Branch: main
  • Folder: / (root)

Diese Einstellung passt zum Workflow. Der Inhalt von Hugos public/-Verzeichnis wird direkt in das Wurzelverzeichnis von main kopiert. Anschließend sorgt ein gewöhnlicher Git-Push dafür, dass GitHub Pages diesen Branch veröffentlicht.

Der Push erfolgt mit dem SSH-Deploy-Key. Dieses Detail ist wichtig, weil GitHub dokumentiert, dass Commits eines Workflows mit dem GITHUB_TOKEN keinen Pages-Build auslösen. Der Deploy-Key ermöglicht den Zugriff auf das zweite Repository und macht die Aktualisierung zu einem normalen Push auf den von Pages verwendeten Branch.

Zusätzlich liegt eine leere Datei static/.nojekyll im Hugo-Quelltext. Hugo kopiert sie als .nojekyll in das Wurzelverzeichnis der generierten Website. Dadurch weiß das Branch-basierte GitHub Pages, dass die vorhandene statische Ausgabe ohne Verarbeitung durch Jekyll ausgeliefert werden soll.

Eine eigene Domain hinzufügen #

Für meine eigene Domain enthält der Hugo-Quelltext die Datei static/CNAME mit dem Hostnamen:

constantinsblog.eu

Hugo kopiert diese Datei nach public/, und der Workflow veröffentlicht sie im Wurzelverzeichnis des öffentlichen Repositories. So bleiben die Domaininformationen Teil jedes Deployments, statt beim nächsten Ersetzen der generierten Dateien durch rsync --delete entfernt zu werden.

Die zugehörigen DNS-Einträge müssen auf GitHub Pages zeigen. In den Pages-Einstellungen von GitHub sind die erforderlichen Einträge aufgeführt. Nach der Verifizierung der Domain verwaltet GitHub auch HTTPS. Die Domain selbst kann weiterhin Registrierungsgebühren verursachen, aber für dieses Setup sind weder eine separate virtuelle Maschine noch ein zusätzlicher Webhosting-Tarif erforderlich.

Abwägungen und Sicherheitsgrenzen #

GitHub unterstützt außerdem Artefakt-basierte Pages-Deployments mit actions/upload-pages-artifact und actions/deploy-pages. Wenn der verwendete GitHub-Tarif ein Pages-Deployment direkt aus dem privaten Quellrepository erlaubt, entfallen bei diesem Ansatz das zweite Repository und der SSH-Schlüssel.

Ich habe mich für das separate öffentliche Repository entschieden, weil es eine klare Grenze zwischen privatem Quelltext und öffentlicher Ausgabe schafft. Es funktioniert außerdem, wenn die Website aus einem öffentlichen Repository veröffentlicht werden muss. Die Nachteile sind die zusätzliche Einrichtung, generierte Commits und die Verantwortung für den Schutz des Deploy-Keys.

Entscheidend ist, dass die Sichtbarkeit des Repositories keine Datei schützt, die nach public/ kopiert wird. Entwürfe, Notizen, Metadaten und statische Dateien werden öffentlich, sobald Hugo sie in den Build aufnimmt. Deshalb markiere ich Entwürfe entsprechend und behandle das generierte Verzeichnis als Inhalt, den jeder einsehen kann.

Die entstandene Grenze #

Mit diesem Setup beginnt eine Veröffentlichung mit einem Push in das private Repository und endet damit, dass GitHub Pages einen Commit aus dem öffentlichen Repository ausliefert. Build, Prüfung und Synchronisierung laufen ohne einen Deployment-Befehl auf meinem Laptop und ohne einen Server, den ich warten muss.

Die nützliche Grenze verläuft nicht zwischen einer privaten und einer öffentlichen Website. Sie liegt zwischen privatem Quellmaterial und öffentlicher Build-Ausgabe. Hugo erzeugt diese Grenze, GitHub Actions prüft sie, und das Deployment-Repository enthält nur die Dateien, die GitHub Pages ausliefern muss.

Constantin Ponfick
Autor
Constantin Ponfick
Ich schreibe über Softwareentwicklung, Cloud-Entwicklung, KI-unterstützte Entwicklung und persönliche Projekte.