My Very Own GitHub Pages

Joshua SeiglerJune 15, 2025

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:

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
	}
}

# Webhooks
https://webhooks.subdomain.here.tld { <<<< CHANGE THIS <<<<
	reverse_proxy localhost:9000
}

# Filter for which SSL certs we will create. Prevents abuse.
http://localhost {
	handle /check {
		root * /var/www
		@deny not file /{query.domain}/
		respond @deny 404
	}
}

# This automatically handles upgrading http:// requests with a redirect
https:// {
	tls {
		on_demand
	}
	root /var/www
	rewrite /{host}{uri}
	@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.

Webhooks

In my home directory I defined two hook scripts:

~/webhooks/update-pages.sh

#!/bin/bash
# parameter 1 is repo name, parameter 2 is clone url
[[ "$1" == *"/"* ]] && exit 1;
[[ "$1" == *".."* ]] && exit 1;
[[ "$1" == *"*"* ]] && exit 1;
if [ -d "/var/www/$1" ]; then
	git clone -b gh-pages --single-branch "$2" "$1" || exit 1;
	exit;
fi;
cd "/var/www/$1";
git fetch origin gh-pages;
git reset --hard origin/gh-pages;
exit;

~/webhooks/remove-pages.sh

#!/bin/bash
# parameter 1 is repo name
[[ "$1" == *"/"* ]] && exit 1;
[[ "$1" == *".."* ]] && exit 1;
[[ "$1" == *"*"* ]] && exit 1;
[ -d "/var/www/$1" ] || exit 1;
cd "/var/www";
rm -rf "/var/www/$1";

To trigger these hooks I am using webhook which is in the default Debian repository.

Here are the hook definitions: one for creating/updating a site, and one for deleting. You will need to generate one or two secret values that the server can use to know that the webhook is authorized to run. I used linux command uuidgen -r to create mine. Save these values so you can enter them in Forgejo later.

Also make sure to replace your execute-command lines with ones referencing your username and script paths.

/etc/webhook.conf

[
	{
		"id": "update-pages",
		"execute-command": "su joshua /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"
						}
					}
				}
			]
		}
	},
	{
		"id": "remove-pages",
		"execute-command": "su joshua /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"
						}
					}
				}
			]
		}
	}
]

Forgejo

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

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

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

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/

For repos with npm build scripts, I use gh-pages @ npm to push the build to the gh-pages branch and up to the server.

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