joshua.seigler.net/site/posts/2025-06-15-replacing-github-pages.md
2025-06-15 01:11:15 -04:00

9.1 KiB

title slug description
My Very Own GitHub Pages my-very-own-github-pages How to self-host Forgejo and automatically serve your web build branches with SSL

I recently started self-hosting Forgejo, but I wanted something to replace GitHub pages, which has been very convenient for publishing little website projects. My server runs Debian, so I decided to use webhook and Caddy. I'm very happy how it turned out.

The result

When I push a gh-pages branch to any public repository on my Forgejo instance, the name of the repo is used as a domain name (e.g. marklink.pages.seigler.net) and the branch contents are automatically served with SSL. If I push updates to the branch, they are automatically published. If the branch or repo is deleted, the site is taken down.

How to do it

Debian server preparation

In case you don't have a basic server setup routine yet, this is a good start:

  • Change the default root password
  • Create a new user, add it to the sudo group. In my examples below the user is joshua.
  • Use ssh-copy-id to install your ssl pubkey for easier login
  • Disable/lock root's password
  • Disable root login over ssh and change the SSL port number. Pick a new port lower than 1024.
  • Edit your local ~/.ssh/config so you don't have to specify the port number every time you connect.
  • On the server, install and enable ufw and fail2ban. In addition to allowing your custom SSL port, be sure to enable ports 80 and 443 with sudo ufw allow "WWW Full".

Caddy

I usually use nginx, but I wanted to give Caddy a shot, and it has been a great experience. I installed Caddy using the official instructions. Here is the Caddyfile I made - you will need to change the domains names and the email. Email could be removed, but it is recommended so SSL certificate issues can contact you if there is a problem with your certificates.

/etc/caddy/Caddyfile

# Global options block
{
		email you@example.com # <<<< change this
        on_demand_tls {
                ask http://localhost/check
        }
}

omitted.webhooks.subdomain.tld { # <<<< change this
        reverse_proxy localhost:9000
}

http://localhost {
        handle /check {
                root * /var/www
                @deny not file /{query.domain}/
                respond @deny 404
        }
}

https:// {
        tls {
                on_demand
        }
        root /var/www
        rewrite /{host}{uri}
        # Block files that start with a .
        @forbidden {
                path /.*
        }
        respond @forbidden 404
        file_server
}

# Refer to the Caddy docs for more information:
# https://caddyserver.com/docs/caddyfile

# This config based on information at
# https://caddy.community/t/on-demand-tls-with-dynamic-content-paths/18140
# checked and corrected with `caddy validate`

I also took ownership of /var/www with chown -R joshua:joshua /var/www since the webhooks will run as my login account.

Webhook

I altered the systemd service definition for webhook so I could organize the hook definitions into separate files. I also set User=joshua and Group=joshua so the commands run as my user instead of root.

sudo mkdir /etc/webhook.conf.d/

/lib/systemd/system/webhook.service

[Unit]
Description=Small server for creating HTTP endpoints (hooks)
Documentation=https://github.com/adnanh/webhook/
ConditionPathExists=/etc/webhook.conf

[Service]
ExecStart=/usr/bin/webhook -nopanic -hooks /etc/webhook.conf.d/*.conf

User=joshua
Group=joshua

[Install]
WantedBy=multi-user.target

If you are debugging your webhook output, consider adding -debug next to -nopanic for more useful logs.

After changing the service definition, reload systemd to run the updated service:

sudo systemctl daemon-reload

Then you can remove the now-unused config file:

sudo rm /etc/webhook.conf

Here are the three hook definitions:

Create pages

/etc/webhook.conf.d/create-pages.conf

[
  {
    "id": "create-pages",
    "execute-command": "/home/joshua/webhooks/create-pages.sh",
    "command-working-directory": "/var/www",
    "pass-arguments-to-command":
    [
      {
        "source": "payload",
        "name": "repository.name"
      },
      {
        "source": "payload",
        "name": "clone_url",
      }
    ],
    "trigger-rule":
    {
      "and":
      [
        {
          "match":
          {
            "type": "payload-hmac-sha256",
            "secret": "(omitted)",
            "parameter":
            {
              "source": "header",
              "name": "X-Forgejo-Signature"
            }
          }
        }
      ]
    }
  }
]

Remove pages

/etc/webhook.conf.d/remove-pages.conf

[
  {
    "id": "remove-pages",
    "execute-command": "/home/joshua/webhooks/remove-pages.sh",
    "command-working-directory": "/var/www",
    "pass-arguments-to-command":
    [
      {
        "source": "payload",
        "name": "repository.name"
      },
    ],
    "trigger-rule":
    {
      "and":
      [
        {
          "match":
          {
            "type": "payload-hmac-sha256",
            "secret": "(omitted)",
            "parameter":
            {
              "source": "header",
              "name": "X-Forgejo-Signature"
            }
          }
        }
      ]
    }
  }
]

Update pages

/etc/webhook.conf.d/update-pages.conf

[
  {
    "id": "update-pages",
    "execute-command": "/home/joshua/webhooks/update-pages.sh",
    "command-working-directory": "/var/www",
    "pass-arguments-to-command":
    [
      {
        "source": "payload",
        "name": "repository.name"
      },
    ],
    "trigger-rule":
    {
      "and":
      [
        {
          "match":
          {
            "type": "payload-hmac-sha256",
            "secret": "(omitted)",
            "parameter":
            {
              "source": "header",
              "name": "X-Forgejo-Signature"
            }
          }
        },
        {
          "match":
          {
            "type": "value",
            "value": "refs/heads/gh-pages",
            "parameter":
            {
              "source": "payload",
              "name": "ref"
            }
          }
        }
      ]
    }
  }
]

In my home directory I defined all three hook scripts:

webhooks/create-pages.sh

#!/bin/bash
# parameter 1 is repo name, parameter 2 is clone URL
[[ "$1" == *"/"* ]] && exit 1; # no slashes in the name
[[ "$1" == *".."* ]] && exit 1; # no .. in the name
[[ "$1" == *"*"* ]] && exit 1; # no wildcards in the name
[ -d "/var/www/$1" ] && exit 1; # the directory must not exist
cd "/var/www";
git clone -b gh-pages --single-branch "$2" "$1" || exit 1;

webhooks/remove-pages.sh

#!/bin/bash
# parameter 1 is repo name
[[ "$1" == *"/"* ]] && exit 1; # no slashes in the name
[[ "$1" == *".."* ]] && exit 1; # no .. in the name
[[ "$1" == *"*"* ]] && exit 1; # no wildcards in the name
[ -d "/var/www/$1" ] && exit 1; # the directory must exist
cd "/var/www";
rm -rf "/var/www/$1";

webhooks/update-pages.sh

#!/bin/bash
# parameter 1 is repo name
[[ "$1" == *"/"* ]] && exit 1; # no slashes in the name
[[ "$1" == *".."* ]] && exit 1; # no .. in the name
[[ "$1" == *"*"* ]] && exit 1; # no wildcards in the name
[ -d "/var/www/$1" ] || exit 1; # the directory must exist
cd "/var/www/$1";
git fetch origin gh-pages;
git reset --hard origin/gh-pages;
exit;

Forgejo

Forgejo supports running webhooks conditionally triggered by certain conditions. Under my main user settings I set up each webhook:

Create pages

Target URL: https:// your domain here /hooks/create-pages HTTP Method: POST (the default) POST content type: application/json (the default) Secret: omitted, use your own Trigger on: Custom Events > Create Branch filter: gh-pages

Remove pages

Target URL: https:// your domain here /hooks/remove-pages HTTP Method: POST (the default) POST content type: application/json (the default) Secret: omitted, use your own Trigger on: Custom Events > Repository > Delete Branch filter: gh-pages

Update pages

Target URL: https:// your domain here /hooks/update-pages HTTP Method: POST (the default) POST content type: application/json (the default) Secret: omitted, use your own Trigger on: Push events Branch filter: gh-pages

Conclusion

It works! This repo is in my Forgejo instance: https://git.apps.seigler.net/joshua/marklink.pages.seigler.net And its contents are visible here on my Caddy server: https://marklink.pages.seigler.net/

There is room to make the scripts a little smarter. They don't handle renaming very well right now, and a few times I had to log in and manually run my webhook scripts, like this:

~/webhooks/create-pages.sh marklink.pages.seigler.net "https://git.apps.seigler.net/joshua/marklink.pages.seigler.net.git"

The really important thing is that updates just require pushing to gh-pages which you can easily do with e.g. gh-pages @ npm.

I'm also putting off rolling my own CI server, but I imagine that's the next stage here. Stay tuned.