Webhooks as a (Systemd) Service

by
Annika Backstrom
in Web, on 12 April 2025. eleventymastodoncodebergwebhooks

The "docs" microsite for xoxo.zone is a static page built with Eleventy. I explicitly didn't want to overcomplicate the site's setup with a cloud build process triggered by commit actions: the static site is compiled locally and committed alongside the content changes. A cron on the server runs git pull on the repo every 5 minutes. The web server can directly serve the site without any additional build.

The site is updated a couple times a month on average. Of the 8,640 average monthly cron git pulls, 8,638 will do nothing. The net impact of this is probably negligible, but it did annoy me. Besides, Codeberg (my forge of choice for xoxo) gets the occasional DDoS and I'm sure is getting hammered by "AI" scrapers that never sleep. Why send them more traffic than necessary?

I wanted to update the site when I pushed to the repo, without a complex configuration that would be difficult for someone else to pick up. Maybe I could write a lightweight web server that listened for a request and perform an action?

Stop giving things generic names

The obvious choice for triggering an action on push is a Webhook, which Codeberg supports natively. Expose an endpoint, verify incoming requests, run a command.

Doing some preliminary searches, I stumbled across the extremely generically-named webhook, a Go web server for triggering commands based on webhook requests. It's apt-installable on Ubuntu, has a simple configuration file syntax in JSON or YAML, and doesn't mind being proxied behind nginx. From a maintenance perspective, that's better than rolling my own server.

Webhook configuration

After apt install webhook, I customised some of the launch parameters with systemctl edit webhook1:

[Unit]
ConditionPathExists=
ConditionPathExists=/etc/webhook.yaml

[Service]
ExecStart=
ExecStart=/usr/bin/webhook -nopanic -hooks /etc/webhook.yaml -ip 127.0.0.1 -port 9899 -urlprefix my-webhook-prefix
User=www-data
Group=www-data

I configured my hook in /etc/webhook.yaml. webhook has an awkward syntax for passing arguments to commands, so I made a small bin script to wrap cd and git pull .... webhook also includes "matchers" to further customise the hook based on incoming parameters. This config performs request signature verification based on a shared secret, and filters for push events.

- id: deploy-docs
  execute-command: /usr/local/bin/xoxo-docs-pull
  response-message: ok
  trigger-rule:
      and:
          - match:
                type: payload-hmac-sha256
                secret: my_secret
                parameter:
                    source: header
                    name: X-Forgejo-Signature
          - match:
                type: value
                value: push
                parameter:
                    source: header
                    name: X-Forgejo-Event

Then I proxied the requests through Nginx:

upstream webhooks {
    server 127.0.0.1:9899 fail_timeout=5;
}

server {
    # the rest of the server config...

    location ~ ^/my-webhook-prefix/ {
       proxy_pass http://webhooks;
    }
}

That's enough to get a working webhook. No more cron needed!

The future

Mastodon itself supports webhooks, and I'd love to improve our admin hooks in the future. Today we get a Discord message when a report is created, but it's not very readable and there's no ability to update the initial message when the report is actioned. webhook feels like a good starting place to improve that experience.

  1. Technically these are in an Ansible playbook, but I'm simplifying so the code examples are more self-contained.