Ableger

Getting Started

Technische Dokumentation. Eine eigene Ableger-Instanz von den Vorlagen-Repos bis zum ersten funktionierenden Deploy.

Beispielwerte in diesem Text (git.example.org, ableger.example.org, deine-org) stehen für die Werte, die bei einer eigenen Installation tatsächlich verwendet werden.

Ergebnis

  • Zwei Gitea-Repos: deine-org/website (Website + CMS-Code, aus der Vorlage erzeugt) und deine-org/methoden (Methoden-Inhalte, ebenfalls aus einer Vorlage erzeugt)
  • Ein Server, der bei jedem Commit (spätestens per Cron) die Website neu baut und veröffentlicht
  • Eine erreichbare Website unter der eigenen Domain, inklusive /admin/ (Decap CMS) mit funktionierendem Login gegen Gitea

Voraussetzungen

  • Eine laufende Gitea- oder Forgejo-Instanz mit Owner-Rechten in einer Organisation (alternativ in einem persönlichen Account – außer für den privaten Modus, der zwingend eine Organisation braucht, siehe Schnittstellen, „Voraussetzung: Organisation statt persönliches Konto“)
  • Ein Server mit Shell-Zugang (SSH), auf dem sich ein eigenes Binary ablegen und ein Cron-Job einrichten lässt
  • Eine eigene Domain, die auf diesen Server zeigt
  • Ruby (für die Hilfsskripte) und git auf dem Server

Gitea muss nicht auf dem Deploy-Server liegen. Siehe hierzu weiter unten „Ein Server oder zwei“. Liegt beides auf einem Server, entfällt der SSH-Umweg für den Deploy-Hook.

In dieser Erklärung geben wir spezifische Anweisungen für die Installation auf uberspace. Diese Anweisungen müssten entsprechend angepasst werden, wenn ein anderes/ eigenes Hosting betrieben wird.

1. Repos aus der Vorlage erzeugen

Beide Vorlagen-Repos sind als Template Repository angelegt. Über den Button „Use this template“ entsteht eine eigene Kopie mit sauberer, eigener Historie (empfohlen für den Normalfall). Wenn du später Änderungen in das Original einbringen möchtest, forkst du lieber.

Für beide Repos (Website und Methoden) wiederholen. Nimm beide idealerweise in die gleiche Organisation/ den gleichen persönlichen Account auf.

2. install.env ausfüllen

Im frisch erzeugten Website-Repo lokal auschecken und install.env an die eigenen Werte anpassen:

GITEA_DOMAIN="git.example.org"
METHODEN_REPO="deine-org/methoden"
OAUTH_CLIENT_ID=""              # zunächst leer lassen, siehe unten
WEBSITE_DOMAIN="ableger.example.org"
DEPLOY_DOCROOT="/var/www/virtual/<account>/ableger.example.org"
STATUS_SERVER_PORT="4182"
Hinweis

OAUTH_CLIENT_ID bleibt an dieser Stelle bewusst leer: Die Client-ID entsteht erst in Schritt 9, wenn die OAuth2-Anwendung in Gitea angelegt wird (Schnittstellen, Schritt 1) – ohne sie funktioniert nur der Login in /admin/ noch nicht, alles andere in diesem Abschnitt lässt sich trotzdem schon testen. Wer möchte, kann Schritt 9 auch vorziehen und install.env gleich vollständig ausfüllen.

Alles, was aus install.env generiert wird (hugo/hugo.toml, admin/config.yml), lokal einmal erzeugen und zur Kontrolle bauen:

./scripts/sync-methoden.sh
./scripts/generate-config.sh
./scripts/generate-version-info.sh
cd hugo && hugo server

Erst wenn das lokal fehlerfrei läuft, lohnt sich der nächste Schritt auf dem Server.

3. Domain und Docroot

uberspace web domain add ableger.example.org

(oder das Äquivalent des eigenen Hosters). Gibt die DNS-Zielwerte aus.

Ein eigenes Docroot ist nur nötig, wenn derselbe Server bereits mehrere Domains bedient (oder zukünftig bedienen soll) und diese nicht denselben Ordner teilen sollen (bei Uberspace ist das der Standardfall: alle Domains eines Accounts landen sonst in ~/html). Betreibt der Account ausschließlich Ableger, kann dieser Schritt entfallen und DEPLOY_DOCROOT direkt auf ~/html zeigen.

mkdir -p /var/www/virtual/<account>/ableger.example.org
printf "RewriteBase /\n" > /var/www/virtual/<account>/ableger.example.org/.htaccess
chmod -R u=rwX,go=rX /var/www/virtual/<account>/ableger.example.org

4. Hugo installieren

Ältere Server haben oft ein zu altes glibc für die Extended-Variante von Hugo (braucht CGO). Die reguläre Variante reicht (Sass/SCSS kommen nicht zum Einsatz).

cd ~
curl -sL -o /tmp/hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v0.164.0/hugo_0.164.0_linux-amd64.tar.gz
mkdir -p ~/bin
tar -xzf /tmp/hugo.tar.gz -C ~/bin hugo
rm /tmp/hugo.tar.gz

Zum Merken für später, kein Handlungsschritt jetzt: ~/bin steht in einer interaktiven SSH-Sitzung meist im $PATH (Aufrufe wie hugo version funktionieren also direkt) – aber nicht automatisch in nicht-interaktiven Sessions wie Git-Hooks. Der Deploy-Hook in Schritt 7 verwendet deshalb durchgängig den vollen Pfad ~/bin/hugo statt nur hugo; das ist dort bereits so vorbereitet, hier ist nichts weiter zu tun.

5. Pagefind installieren

Pagefind ist ein statisches Binary ohne Laufzeit-Abhängigkeiten.

cd ~
curl -sL -o /tmp/pagefind.tar.gz https://github.com/CloudCannon/pagefind/releases/download/v1.5.2/pagefind-v1.5.2-x86_64-unknown-linux-musl.tar.gz
tar -xzf /tmp/pagefind.tar.gz -C ~/bin pagefind
rm /tmp/pagefind.tar.gz

Die -musl-Variante ist statisch gelinkt – anders als bei Hugo oben spielt ein altes glibc hier keine Rolle.

6. Ruby-Abhängigkeiten prüfen

Die Hilfsskripte (sync-methoden.sh, validate.rb, apply-veroeffentlicht.rb und weitere) laufen mit dem System-Ruby. webrick ist seit Ruby 3.0 nicht mehr Teil der Standardbibliothek. Wir verwenden es für den Admin-Status-Server (Schritt 10). Zwei getrennte Schritte:

Zuerst prüfen, ob webrick schon verfügbar ist:

ruby -e "require 'webrick'"

Kommt keine Fehlermeldung, ist nichts weiter zu tun – auf diesen Servern ist webrick entweder noch Teil der Standardbibliothek (Ruby < 3.0) oder war bereits nachinstalliert.

Meldet der Befehl cannot load such file -- webrick, nachinstallieren:

gem install --user-install webrick

7. Bare-Repo und Deploy-Hook

Dieser Schritt richtet den eigentlichen Automatismus ein: ein leeres Git-Repo auf dem Server, mit einem Skript (dem „Deploy-Hook“), das bei jedem eingehenden Push automatisch ausgeführt wird. Das Skript holt sich den aktuellen Stand aus Gitea, baut die Hugo-Website neu und kopiert das Ergebnis ins Docroot – ohne dass jemand manuell auf dem Server etwas anstoßen muss. Ergänzt um den Cron-Job unten läuft derselbe Ablauf auch dann, wenn ein Commit nicht per git push auf diesen Server kam (Giteas Web-Editor, gemergte Pull Requests), sondern nur in Gitea selbst entstanden ist.

mkdir -p ~/repos/website.git
cd ~/repos/website.git
git init --bare -b main

~/repos/website.git/hooks/post-receive (ausführbar, chmod +x) – Grundgerüst, unabhängig davon, ob Gitea auf demselben Server läuft:

#!/bin/bash
set -uo pipefail
unset GIT_DIR GIT_WORK_TREE

WORKTREE="$HOME/deploy/website"
HUGO="$HOME/bin/hugo"
PAGEFIND="$HOME/bin/pagefind"
STATUS_FILE="$HOME/deploy/status.json"
LOCK_FILE="$HOME/deploy/deploy.lock"

# Guards against concurrent runs (cron and the manual trigger via
# /admin/status/, see step 10) - non-blocking: if one is already running,
# this run aborts immediately instead of waiting or duplicating work.
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
  # The skipped run also writes an honest status.json entry (see below) -
  # otherwise the status page would keep showing the last *successful* run
  # after a missed trigger, as if a fresh build had just happened.
  ruby -rjson -rtime -e '
    status = { timestamp: Time.now.iso8601, success: false,
               log: "Deploy laeuft bereits, dieser Lauf wurde uebersprungen." }
    File.write(ARGV[0], JSON.pretty_generate(status))
  ' "$STATUS_FILE"
  echo "Deploy läuft bereits, dieser Lauf wird übersprungen: $(date)"
  exit 0
fi

LOG="$(mktemp)"
(
  set -e
  BAREREPO="$HOME/repos/website.git"   # separate Gitea server: use an SSH URL instead of a file path, see "Ein Server oder zwei" below

  if [ ! -d "$WORKTREE/.git" ]; then
    rm -rf "$WORKTREE"
    git clone "$BAREREPO" "$WORKTREE"
  fi

  cd "$WORKTREE"
  git remote set-url origin "$BAREREPO"
  git fetch origin main
  git reset --hard origin/main

  source "$WORKTREE/install.env"
  DOCROOT="$DEPLOY_DOCROOT"

  METHODEN_REPO_URL="<pfad oder ssh-url zum methoden-repo>" \
    "$WORKTREE/scripts/sync-methoden.sh"

  "$WORKTREE/scripts/generate-config.sh"
  "$WORKTREE/scripts/generate-version-info.sh"

  cd "$WORKTREE/hugo"
  "$HUGO" --gc --minify --cleanDestinationDir

  "$PAGEFIND" --site "$WORKTREE/hugo/public"

  rsync -a --delete --exclude='.htaccess' "$WORKTREE/hugo/public/" "$DOCROOT/"
  rsync -a "$WORKTREE/admin/" "$DOCROOT/admin/"
  rsync -a "$WORKTREE/methoden-set/" "$DOCROOT/methoden-set/"
  ruby "$WORKTREE/scripts/generate-vorlagen-manifest.rb" "$DOCROOT/methoden-set/vorlagen"

  echo "Deploy fertig: $(date)"
) > "$LOG" 2>&1
EXIT_CODE=$?

cat "$LOG"

ruby -rjson -rtime -e '
  log = File.read(ARGV[0])
  status = { timestamp: Time.now.iso8601, success: ARGV[1] == "0", log: log }
  File.write(ARGV[2], JSON.pretty_generate(status))
' "$LOG" "$EXIT_CODE" "$STATUS_FILE"

rm -f "$LOG"
exit "$EXIT_CODE"

Ergänzend ein Cron-Eintrag (z. B. alle 10 Minuten), der denselben Hook manuell aufruft – fängt auch Commits ab, die nie über git push auf diesen Server liefen (Giteas Web-Editor, gemergte Pull Requests):

*/10 * * * * ~/repos/website.git/hooks/post-receive < /dev/null > /dev/null 2>&1

Wichtige Einzelpunkte:

  • admin/ und methoden-set/ separat kopieren: Beide liegen am Repo-Root, nicht unter hugo/ und landen damit nicht automatisch im Hugo-Build-Output. Sie brauchen daher je eine eigene rsync-Zeile.
  • admin/config.yml und hugo/hugo.toml werden bei jedem Deploy neu generiert, weshalb generate-config.sh (und generate-version-info.sh) im Hook liegen, nicht nur lokal.
  • --cleanDestinationDir nicht vergessen: Ohne dieses Flag räumt Hugo hugo/public/ bei jedem Build nicht auf. Die seit dem letzten Build entfernte Seiten blieben ohne Aufräumen über rsync --delete dauerhaft live erreichbar.
  • set -e gehört in eine echte Subshell ( ... ), nicht in eine Gruppe { ... }: Eine Gruppe läuft im selben Shell-Kontext – ein darin ausgelöster Abbruch würde das ganze Skript sofort beenden, bevor EXIT_CODE=$? und das Schreiben der Status-Datei danach überhaupt erreicht würden.
  • Pagefind läuft nach dem Hugo-Build, nicht davor: Es indiziert die fertigen HTML-Dateien in hugo/public/.

Ein Server oder zwei

Läuft der Deploy-Hook auf demselben Server wie Gitea, kann BAREREPO direkt auf den lokalen Dateipfad von Giteas Repository-Verzeichnis zeigen (z. B. ~/gitea/data/gitea-repositories/<org>/website.git). Das spart den Umweg übers Netzwerk und einen Deploy-Key. Das gilt entsprechend auch für sync-methoden.sh.

Laufen Gitea und der Deploy-Server getrennt braucht es stattdessen:

  1. Einen Deploy Key (SSH-Schlüsselpaar, nur Lesezugriff) je Repo in Gitea unter „Repo-Einstellungen → Deploy Keys“ hinterlegen.
  2. BAREREPO bzw. METHODEN_REPO_URL als SSH-URL (ssh://git@git.example.org:<port>/<org>/website.git) statt als lokaler Pfad.
  3. In ~/.ssh/config des Deploy-Servers den Host samt Schlüssel eintragen.
Warnung

Bei älterem OpenSSH: StrictHostKeyChecking accept-new wird erst ab OpenSSH 7.6 unterstützt (spart das interaktive Bestätigen beim ersten Verbindungsaufbau). Meldet ssh -V eine ältere Version, bricht ein automatisierter Hook beim allerersten Verbindungsaufbau ab, weil niemand interaktiv bestätigen kann. Workaround: den Host-Key vorab einmalig einsammeln, statt auf interaktive Bestätigung zu setzen:

ssh-keyscan -p <port> git.example.org >> ~/.ssh/known_hosts

Mehrere Ableger-Instanzen auf einem Account

Vorsicht bei der Namensgebung von Bare-Repo, Status-Datei und Deploy-Hook-Pfad, wenn auf demselben Server bereits eine andere Ableger-Instanz (Produktiv + Staging/Demo) oder irgendein anderes Deployment mit Git-Hooks läuft.

Der naheliegende Name für das Bare-Repo aus Schritt 7 ist website.git (folgt direkt aus WEBSITE_REPO="<org>/website"). Das funktioniert für eine einzelne Instanz problemlos, kollidiert aber, sobald ein zweites Deployment auf demselben Account ebenfalls ~/repos/website.git heißen will.

Warnung

Symptom, falls das passiert: Der Trigger-Button auf /admin/status/ löst den Deploy-Hook des anderen Deployments aus statt den eigenen, und die eigene Status-Seite zeigt dauerhaft „noch kein Deploy-Status vorhanden“ statt echter Daten. scripts/admin-status-server.rb leitet Status-Datei und Hook-Pfad standardmäßig aus WEBSITE_REPO ab, ohne zu prüfen, ob der abgeleitete Name schon anderweitig vergeben ist.

Lösung: Bare-Repo unter einem eindeutigen Namen anlegen (z. B. ~/repos/<eigener-name>.git statt ~/repos/website.git) und die beiden zugehörigen install.env-Werte explizit setzen, statt sie auf dem Standard zu belassen:

DEPLOY_STATUS_FILE="~/deploy/<eigener-name>-status.json"
DEPLOY_HOOK_PATH="~/repos/<eigener-name>.git/hooks/post-receive"

Beide Werte müssen exakt mit STATUS_FILE bzw. dem tatsächlichen Pfad des Hook-Skripts aus Schritt 7 übereinstimmen. Bei nur einer Ableger-Instanz pro Account reicht der Standard aus beiden Variablen (~/deploy/status.json bzw. aus WEBSITE_REPO abgeleitet).

8. Ersten Deploy auslösen und prüfen

git remote add deploy ssh://<user>@<server>/home/<user>/repos/website.git
git push deploy main

Danach:

curl -s -o /dev/null -w '%{http_code}\n' https://ableger.example.org/

Ohne aktives DNS lässt sich der Server direkt per IP ansprechen, zum Testen vor der DNS-Propagierung:

curl -sk --resolve ableger.example.org:443:<server-ip> https://ableger.example.org/

9. OAuth einrichten

Damit /admin/ (Decap CMS) sich gegen Gitea anmelden kann, braucht es eine OAuth2-Anwendung in Gitea und eine CORS-Freigabe: Schnittstellen, Teil 1.

10. Admin-Status-Bereich einrichten

/admin/status/ zeigt Zeitpunkt und Ausgang des letzten Deploys und kann einen Build manuell auslösen. Dafür läuft scripts/admin-status-server.rb (WEBrick-basiert) als eigener, dauerhafter Prozess – bei Uberspace über ~/etc/services.d/*.ini + supervisorctl, analog zu Gitea selbst. Läuft auf demselben Account bereits ein anderes Deployment: unbedingt erst „Mehrere Ableger-Instanzen auf einem Account“ weiter oben lesen, bevor dieser Dienst gestartet wird.

Warnung

Auch bei einer rein öffentlichen Instanz ohne privaten Modus: Der Prozess lauscht nur auf einem lokalen Port (STATUS_SERVER_PORT aus install.env) und ist damit nicht von außen erreichbar, bis eine Route dorthin eingerichtet wird:

uberspace web backend set 'ableger.example.org/admin/status/' --http --port 4182

Ohne diesen Schritt liefert /admin/status/ zwar eine Seite aus (die statische index.html), aber jeder Aufruf der zugehörigen API (/admin/status/api/data.json, Build-Button) läuft ins Leere – die Seite meldet mitunter trotzdem „Fertig“, ohne dass tatsächlich ein Build angestoßen wurde.

Bekannt und zu beachten: der Status-Bereich ist standardmäßig öffentlich erreichbar

Warnung

Ohne den privaten Modus (siehe Schnittstellen, Teil 2) liegt /admin/status/ nicht hinter einem Login – jede:r kann Deploy-Log und -Zeitpunkt einsehen und einen Build auslösen. Für die meisten Instanzen unkritisch (keine Geheimnisse im Log), aber bei Bedarf ein zusätzlicher Grund, den privaten Modus für /admin/ zu aktivieren.

Prüfen

  1. https://ableger.example.org/ → Methodenliste, inklusive der Beispielmethoden aus dem Vorlagen-Repo.
  2. https://ableger.example.org/admin/ → Login with Gitea, kompletter OAuth-Flow.
  3. https://ableger.example.org/admin/status/ → Deploy-Status, Button zum manuellen Auslösen funktioniert.
  4. Eine Änderung über Decap CMS speichern → landet als Commit im Methoden-Repo → erscheint spätestens nach dem nächsten Cron-Lauf (oder sofort nach manuellem Trigger) auf der Website.