Die Seite läuft auf Uberspace , einem Shared-Hoster ohne fertige PaaS-Pipeline. Statt Push-to-Deploy aus der Box ist das Deployment auf mehrere Komponenten verteilt: Codeberg als Git-Hosting, Woodpecker CI als Build-Runner und ein kleiner Webhook auf dem Uberspace, der das fertige Build-Artefakt entgegennimmt. Der Artikel beschreibt, wie diese Komponenten zusammenspielen.
Den kompletten Source-Code dieser Seite findest du hier:
Big Picture#
flowchart TB
Dev[Entwickler] -->|git push| CB[Codeberg / Forgejo]
CB -->|Webhook| WP[Woodpecker CI]
WP --> Build["Hugo Build
main → production
feature/* → staging"]
Build -->|"tar.gz + JSON
HMAC-SHA512"| WH["webhook.kevinhorst.de
adnanh/webhook"]
WH -->|"environment: production"| Prod["kevinhorst.de"]
WH -->|"environment: staging"| Stg["staging.kevinhorst.de
(Basic Auth)"]
WP -.->|"nur feature/*"| Hurl["Integration Tests
Hurl"]
Hurl -.->|"HTTP + Basic Auth"| Stg
Branches steuern, wohin deployed wird:
main->kevinhorst.de(Production)feature/*->staging.kevinhorst.deund Integration-Tests gegen die Staging-Domain
Pipeline in Woodpecker#
Die Pipeline-Definition liegt unter .woodpecker/deploy.yaml im Repository und wird bei jedem push ausgeführt. Aufgeteilt ist sie in vier Steps: Build & Deploy auf Production, Build & Deploy auf Staging, Integration-Tests auf Staging und ein Junit-Report-Step. Production- und Staging-Step sind über when.branch voneinander getrennt, sodass pro Push immer nur einer von beiden anläuft.
Der Build selbst läuft im offiziellen hugomods/hugo
-Image, das Hugo, Git und die üblichen Asset-Tools mitbringt. --enableGitInfo sorgt dafür, dass Hugo das Datum des letzten Commits als Lastmod jedes Inhalts kennt - praktisch für die “zuletzt aktualisiert”-Anzeige.
Auszug aus dem Production-Step:
- name: build & deploy on production
image: hugomods/hugo:git-0.161.1
environment:
DEPLOY_TOKEN:
from_secret: deploy_token
when:
- branch: main
commands:
- hugo --enableGitInfo --environment=production -s ./site build
- apk add --no-cache curl openssl
- tar -C ./site -czf ${CI_COMMIT_SHA:0:8}.tar.gz public
- >-
echo -n '{"package":"'"$(base64 -w0 -i ./${CI_COMMIT_SHA:0:8}.tar.gz)"'",
"package.name":"'"${CI_COMMIT_SHA:0:8}"'",
"package.environment":"production"}' > data.json
- sig=$(openssl dgst -sha512 -hmac $$DEPLOY_TOKEN data.json | awk '{print "sha512="$$2}')
- >-
curl --fail --silent --show-error
-H "Content-Type: application/json"
-H "X-Signature: $$sig"
-X POST -d @./data.json
https://webhook.kevinhorst.de/hooks/deploy-kevinhorstdeDer Staging-Step ist bis auf --environment=staging und das Branch-Pattern feature/* identisch.
Build-Artefakt erzeugen#
Nach hugo build liegt die fertige Site unter site/public/. Daraus entsteht das Build-Artefakt: ein Tarball, benannt nach den ersten 8 Zeichen des Commit-SHA - genug, um Builds auf dem Server eindeutig zuordnen zu können, ohne das ganze Repository über den Webhook zu pushen.
Der Tarball wird in ein JSON-Payload eingebettet:
{
"package": "<base64-codierter-tarball>",
"package.name": "51daa09e",
"package.environment": "production"
}Das gesamte Bundle ist Bestandteil eines POST-Requests gegen den Webhook. Vorteil dieses Vorgehens: kein SSH, kein offener rsync-Port, kein Build-on-Server. Nachteil: das JSON wird bei größeren Sites sperrig - für eine statische Seite mit ein paar MB ist das aber kein Thema.
Authentifizierung über HMAC-SHA512#
Der Webhook ist öffentlich erreichbar. Damit nicht beliebig deployed werden kann, signiert die Pipeline jedes Payload mit openssl dgst -sha512 -hmac:
sig=$(openssl dgst -sha512 -hmac "$DEPLOY_TOKEN" data.json | awk '{print "sha512="$2}')Die Signatur wandert als X-Signature: sha512=<hex> in den HTTP-Header. Der Webhook auf Uberspace validiert die Signatur, bevor das Deploy-Skript überhaupt startet. Stimmt sie nicht, wird der Request mit 403 abgewiesen.
DEPLOY_TOKEN liegt als Secret in Woodpecker und auf dem Uberspace als Environment-Variable (DEPLOY_SECRET_KEVINHORSTDE) im Supervisor-Config der Webhook-Instanz. Generiert wird der Key einmalig:
openssl rand -hex 32Webhook auf Uberspace#
Auf dem Uberspace läuft adnanh/webhook als langlaufender Prozess unter Supervisor. Die Uberspace-Anleitung deckt die Einrichtung im Wesentlichen ab; die wichtigsten Stellen für diesen Setup:
# ~/etc/services.d/webhook.ini
[program:webhook]
directory=%(ENV_HOME)s/www-head/webhook
environment=DEPLOY_SECRET_KEVINHORSTDE=[hmac512-secret]
command=/home/head/www-head/webhook/webhook
-hooks hooks.json.tmpl -template
-logfile %(ENV_HOME)s/logs/webhook.log
-hotreload -verbose
startsecs=15Aktivieren des Service:
supervisorctl reread
supervisorctl update
supervisorctl statusDamit der Webhook über HTTPS erreichbar ist, wird eine Subdomain auf den lokalen Port gemappt:
uberspace web domain add webhook.kevinhorst.de
uberspace web backend set webhook.kevinhorst.de --http --port 9000Hook-Definition#
Im hooks.json.tmpl ist der eigentliche Hook definiert: HMAC-Verifikation, Decoding des Tarballs und Aufruf des Deploy-Skripts.
[
{
"id": "deploy-kevinhorstde",
"execute-command": "/home/head/www-head/deploy.sh",
"command-working-directory": "/home/head/www-head",
"response-message": "the test-hook was triggered!",
"trigger-rule":
{
"and":
[
{
"match":
{
"type": "payload-hmac-sha512",
"secret": "{{ getenv "DEPLOY_SECRET_KEVINHORSTDE" | js }}",
"parameter":
{
"source": "header",
"name": "X-Signature"
}
}
}
]
},
"pass-arguments-to-command":
[
{ "source": "payload", "name": "package.name" },
{ "source": "payload", "name": "package.environment" }
],
"pass-file-to-command":
[
{
"source": "payload",
"name": "package",
"envname": "ENV_DEPLOY_PACKAGE",
"base64decode": true
}
]
}
]Drei Dinge passieren hier:
trigger-rulemitpayload-hmac-sha512lehnt jeden Request ab, dessenX-Signature-Header nicht zum Secret passt.pass-arguments-to-commandreichtpackage.name(Commit-Hash) undpackage.environment(productionoderstaging) als$1und$2an das Deploy-Skript weiter.pass-file-to-commanddecodiert das base64-codiertepackagezurück in einen Tarball und legt den Pfad alsENV_DEPLOY_PACKAGE-Environment-Variable bereit. Das eigentlichetar -xzfmacht erst das Deploy-Skript.
Das .tmpl-Suffix zeigt, dass webhook die Datei als Go-Template rendert (-template-Flag im Supervisor-Command). Dadurch lässt sich das Secret per getenv zur Laufzeit aus DEPLOY_SECRET_KEVINHORSTDE ziehen, statt es ins Repo zu schreiben.
Deploy-Skript#
Das Skript bekommt zwei Argumente vom Webhook, entpackt den Tarball in ein versioniertes Verzeichnis und schaltet die Live-Domain per Symlink um:
#!/bin/env bash
echo "Deploy package kevinhorst-$1 on $2..."
if [[ $2 == "staging" ]]; then
mkdir -p staging.kevinhorstde-"$1"
tar -xzf "${ENV_DEPLOY_PACKAGE}" -C staging.kevinhorstde-"$1" --strip-components=1
chmod -R u=rwX,go=rX staging.kevinhorstde-"$1"
ln -sfn staging.kevinhorstde-"$1" staging.kevinhorst.de
ln -sfn staging.kevinhorstde-"$1" staging.kevinhorst.com
elif [[ $2 == "production" ]]; then
mkdir -p kevinhorstde-"$1"
tar -xzf "${ENV_DEPLOY_PACKAGE}" -C kevinhorstde-"$1" --strip-components=1
chmod -R u=rwX,go=rX kevinhorstde-"$1"
ln -sfn kevinhorstde-"$1" www.kevinhorst.de
ln -sfn kevinhorstde-"$1" kevinhorst.de
ln -sfn kevinhorstde-"$1" www.kevinhorst.com
ln -sfn kevinhorstde-"$1" kevinhorst.com
else
echo "Unknown environment."
exit 1
fiJeder Build landet also unter einem eigenen Pfad wie kevinhorstde-51daa09e/. Der Symlink-Swap mit ln -sfn ist atomar: während der Webserver einen Request bedient, sieht er entweder den alten oder den neuen Pfad, aber nichts dazwischen. Rollback ist damit ein einzelner ln -sfn-Befehl auf das vorherige Verzeichnis.
Eine Aufräum-Logik für alte Build-Verzeichnisse fehlt bewusst - so lässt sich notfalls auf jeden früheren Stand zurückspringen. Bei Bedarf ein Cron-Job mit find ... -mtime +N -delete.
Pipeline lokal nachstellen#
Bevor eine Änderung im CI landet, lässt sich der komplette Build- und Deploy-Schritt lokal in einem Container ausprobieren:
KEY=$(openssl rand -hex 32) # nur für lokale Tests, niemals committen
COMMIT_SHA=51daa09e
docker run --rm -it -v $(pwd):/src \
-e CI_COMMIT_SHA=$COMMIT_SHA \
-e KEY=$KEY \
hugomods/hugo:git-0.161.1 shIm Container dann analog zur Pipeline:
apk add --no-cache curl openssl
hugo --enableGitInfo --environment=staging -s ./site build
tar -C ./site -czf ${CI_COMMIT_SHA:0:8}.tar.gz public
echo -n '{"package":"'"$(base64 -w0 -i ./${CI_COMMIT_SHA:0:8}.tar.gz)"'",
"package.name":"'"${CI_COMMIT_SHA:0:8}"'",
"package.environment":"staging"}' > data.json
sig=$(openssl dgst -sha512 -hmac $KEY data.json | awk '{print "sha512="$2}')
curl -X POST -H "Content-Type: application/json" \
-H "X-Signature: $sig" \
--data @./data.json \
https://webhook.kevinhorst.de/hooks/deploy-kevinhorstdeDamit das tatsächlich deployed, muss $KEY natürlich dem Token auf dem Uberspace entsprechen. Mit einem zufälligen Key bekommt man stattdessen ein 403 - was zugleich ein hübscher Test für die HMAC-Validierung ist.
Integration-Tests#
Auf feature/*-Branches läuft nach dem Staging-Deploy noch ein Hurl-Schritt gegen staging.kevinhorst.de:
- name: integration tests on staging
image: ghcr.io/orange-opensource/hurl:8.0.1
environment:
STAGING_USER:
from_secret: staging_user
STAGING_PASSWORD:
from_secret: staging_password
commands:
- >-
hurl --test
--user $STAGING_USER:$STAGING_PASSWORD
--variable BASE_URL=https://staging.kevinhorst.de
--report-junit ./tests/report.xml
./tests/*.hurlDie Tests prüfen die wichtigsten Pfade (Homepage, Posts, Impressum, Datenschutz) auf Status 200 und auf Inhalte, die wirklich da sein sollen. Die Staging-Domain ist per Basic Auth abgesichert; die Credentials liegen ebenfalls als Secrets in Woodpecker.
Im Anschluss rendert ein kleiner Junit-Reporter die Ergebnisse direkt im Pipeline-Log.
Verwendete Projekte#
- Hugo - Static-Site-Generator
- hugomods/hugo - Hugo-Container-Image für CI
- Codeberg - Forgejo-basiertes Git-Hosting
- Woodpecker CI - schlanker, container-nativer CI-Runner
- adnanh/webhook - kleiner HTTP-Hook-Server
- Hurl - HTTP-getriebene Integration-Tests
- Uberspace - Shared-Hosting mit SSH, Supervisor und Wahlfreiheit
- Uberspace-Lab: Webhook
- Setup-Guide für
adnanh/webhookauf Uberspace
Hat dir das hier geholfen?
Unterstütze gerne via:
