This site is hosted on Uberspace , a shared host without a ready-made PaaS pipeline. Instead of an out-of-the-box push-to-deploy, the deployment is split across several components: Codeberg as the Git host, Woodpecker CI as the build runner, and a small webhook on the Uberspace that picks up the finished build artefact. This article walks through how these components fit together.
The full source for this site:
Big Picture#
flowchart TB
Dev[Developer] -->|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 -.->|"feature/* only"| Hurl["Integration tests
Hurl"]
Hurl -.->|"HTTP + basic auth"| Stg
Branches drive where things land:
main->kevinhorst.de(production)feature/*->staging.kevinhorst.deplus integration tests against the staging domain
The Woodpecker Pipeline#
The pipeline definition sits at .woodpecker/deploy.yaml in the repository and runs on every push. It has four steps: build & deploy to production, build & deploy to staging, integration tests on staging, and a JUnit report step. The production and staging steps are split via when.branch, so only one of them runs per push.
The build itself runs in the official hugomods/hugo
image, which ships Hugo, Git, and the usual asset tooling. --enableGitInfo makes Hugo pick up the last commit date as Lastmod for each piece of content - handy for the “last updated” line.
Snippet from the 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-kevinhorstdeThe staging step is identical apart from --environment=staging and the feature/* branch pattern.
Creating the Build Artefact#
After hugo build, the finished site sits at site/public/. From there, the build artefact is created: a tarball, named after the first eight characters of the commit SHA - enough to identify a build on the server without having to push the whole repository through the webhook.
The tarball is embedded in a JSON payload:
{
"package": "<base64-encoded-tarball>",
"package.name": "51daa09e",
"package.environment": "production"
}The whole bundle ships as a POST request to the webhook. Upside: no SSH, no open rsync port, no build-on-server. Downside: the JSON gets unwieldy for larger sites - but for a static site of a few MB it is a non-issue.
HMAC-SHA512 Authentication#
The webhook is publicly reachable. To prevent arbitrary deploys, the pipeline signs every payload with openssl dgst -sha512 -hmac:
sig=$(openssl dgst -sha512 -hmac "$DEPLOY_TOKEN" data.json | awk '{print "sha512="$2}')The signature travels in the HTTP header as X-Signature: sha512=<hex>. The webhook on Uberspace verifies it before even starting the deploy script. If it doesn’t match, the request gets a 403.
DEPLOY_TOKEN lives as a secret in Woodpecker and as an environment variable (DEPLOY_SECRET_KEVINHORSTDE) in the supervisor config of the webhook process on the Uberspace. Generate the key once:
openssl rand -hex 32Webhook on Uberspace#
adnanh/webhook runs as a long-running process under Supervisor on the Uberspace. The Uberspace guide covers most of the setup; the relevant bits for this configuration:
# ~/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=15Activate the service:
supervisorctl reread
supervisorctl update
supervisorctl statusFor HTTPS access, a subdomain is mapped to the local port:
uberspace web domain add webhook.kevinhorst.de
uberspace web backend set webhook.kevinhorst.de --http --port 9000Hook Definition#
hooks.json.tmpl defines the actual hook: HMAC verification, tarball decoding, and the call to the deploy script.
[
{
"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
}
]
}
]Three things happen here:
trigger-rulewithpayload-hmac-sha512rejects any request whoseX-Signatureheader doesn’t match the secret.pass-arguments-to-commandforwardspackage.name(the commit hash) andpackage.environment(productionorstaging) as$1and$2to the deploy script.pass-file-to-commanddecodes the base64-encodedpackageback into a tarball and exposes the path as theENV_DEPLOY_PACKAGEenvironment variable. The actualtar -xzfis left to the deploy script.
The .tmpl suffix means webhook renders the file as a Go template (-template flag in the Supervisor command). That way getenv can pull the secret from DEPLOY_SECRET_KEVINHORSTDE at runtime instead of having it sit in the repo.
Deploy Script#
The script receives two arguments from the webhook, unpacks the tarball into a versioned directory and switches the live domain via symlink:
#!/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
fiEach build ends up in its own directory like kevinhorstde-51daa09e/. The symlink swap with ln -sfn is atomic: while the web server is serving a request, it sees either the old or the new path, never anything in between. Rollback is a single ln -sfn away.
There is intentionally no cleanup for old build directories - that way any previous state is one symlink change away. A cron job with find ... -mtime +N -delete can handle that on demand.
Reproducing the Pipeline Locally#
Before pushing changes through CI, the full build-and-deploy can be exercised locally in a container:
KEY=$(openssl rand -hex 32) # local testing only - never commit
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 shInside the container, mirror the 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-kevinhorstdeFor a real deploy $KEY has to match the token on the Uberspace, of course. With a random key the request gets a 403 - which doubles as a nice live test of the HMAC validation.
Integration Tests#
On feature/* branches, a Hurl step runs against staging.kevinhorst.de after the staging deploy:
- 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/*.hurlThe tests check the important paths (homepage, posts, imprint, privacy policy) for 200 and for content that genuinely should be there. The staging domain is protected by basic auth; those credentials are also stored as secrets in Woodpecker.
Afterwards, a small JUnit reporter renders the results straight into the pipeline log.
Acknowledgments#
- Hugo - static site generator
- hugomods/hugo - Hugo container image for CI
- Codeberg - Forgejo-based Git hosting
- Woodpecker CI - lean, container-native CI runner
- adnanh/webhook - small HTTP hook server
- Hurl - HTTP-driven integration tests
- Uberspace - shared hosting with SSH, Supervisor and freedom of choice
- Uberspace Lab: Webhook
- setup guide for
adnanh/webhookon Uberspace
Found this helpful?
Consider supporting via:
