Skip to main content

Behind This Site: How It Gets Deployed

·1294 words·7 mins
Table of Contents

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

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

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

Activate the service:

supervisorctl reread
supervisorctl update
supervisorctl status

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

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

  1. trigger-rule with payload-hmac-sha512 rejects any request whose X-Signature header doesn’t match the secret.
  2. pass-arguments-to-command forwards package.name (the commit hash) and package.environment (production or staging) as $1 and $2 to the deploy script.
  3. pass-file-to-command decodes the base64-encoded package back into a tarball and exposes the path as the ENV_DEPLOY_PACKAGE environment variable. The actual tar -xzf is 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
fi

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

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

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

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


Found this helpful?
Consider supporting via: