Zum Hauptinhalt springen

Hinter den Kulissen: wie diese Seite deployed wird

·1220 Wörter·6 min
Inhaltsverzeichnis

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.de und 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-kevinhorstde

Der 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 32

Webhook 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=15

Aktivieren des Service:

supervisorctl reread
supervisorctl update
supervisorctl status

Damit 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 9000

Hook-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:

  1. trigger-rule mit payload-hmac-sha512 lehnt jeden Request ab, dessen X-Signature-Header nicht zum Secret passt.
  2. pass-arguments-to-command reicht package.name (Commit-Hash) und package.environment (production oder staging) als $1 und $2 an das Deploy-Skript weiter.
  3. pass-file-to-command decodiert das base64-codierte package zurück in einen Tarball und legt den Pfad als ENV_DEPLOY_PACKAGE-Environment-Variable bereit. Das eigentliche tar -xzf macht 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
fi

Jeder 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 sh

Im 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-kevinhorstde

Damit 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/*.hurl

Die 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
#


Hat dir das hier geholfen?
Unterstütze gerne via: