Preamble
So I guess this is now a blog about the infrastructure of this blog. More than a year after the first (and only) post, I’m following up the write-up about how I deployed this blog with one about how I changed that deployment.
Starting point
Last time I noted that the original themes were meant for deployment on GitHub Pages. But in the spirit of self-hosted–ness (self-host–ity?), I decided to go more old-school with an SSH deployment.
The problem
This way of deploying static websites worked fine for decades, but it has a few issues. They all stem from the fact that it requires creating a user with the proper permissions, an SSH key, etc. This is not very scalable (haha, scalable, for one user…). It also implicitly ties you to the machine the HTTP server is running on. Both of these issues are solvable, but it becomes more and more of a house of cards the more automations you implement.
Concretely, the blog was deployed by SCP’ing Hugo’s public/ output to an nginx
container on the server — which in practice meant SSH key management in CI, no
preview deployments for PRs, and manual infrastructure drift.
The solution
It turns out that Catherine (of Whitequark fame) saw the same gap, but actually decided to do something about it. So now we have a very cool git-first way of deploying static pages in the form of git-pages. All the best features of GitHub Pages, wrapped in an easy HTTP interface, self-hosted and secure by design.
What I did
Created a service repo for git-pages
The server-side configuration lives in homelab/dc-git-pages, following the same conventions as my other homelab services.
docker-compose.yml— container definition with Traefik labels for routingpages.martin.md,*.pages.martin.md,preview.pages.martin.md, and*.preview.pages.martin.mdto git-pages on port 3000config.toml— wildcard domain config, filesystem storage, preview domain support, and Forgejo authorization
Switched Traefik to DNS-01 challenge
The Traefik ACME configuration was changed from TLS-ALPN-01 to DNS-01
(Cloudflare). This enables wildcard TLS certificates for *.pages.martin.md
and *.preview.pages.martin.md, so any user subdomain gets HTTPS without
manual intervention.
Split Traefik routers for separate wildcard certs
Git-pages uses subdomain-based routing (<user>.pages.martin.md/<project>/),
so Traefik needed HostRegexp rules for wildcard subdomains.
Rewrote the blog CI workflow
The old workflow used actions/checkout, peaceiris/actions-hugo, and
garygrossgarten/github-action-scp with 4 SSH secrets. The new workflow:
- Clones the repo directly with
git clone - Installs Hugo from GitHub releases (
.debpackage) - Uses
actions/setup-go@v7for Go module support - Builds with
--baseURLset for preview paths - Creates a tar archive of
public/and PUTs it to git-pages - Hugo and theme versions are pinned and tracked by Renovate
Production deploys authenticate via DNS TXT challenge
(Authorization: Pages <token>), previews via Forge-Authorization
(Forge-Authorization: Bearer <token>). No SSH secrets needed.
Set up DNS TXT challenge for production domain
For blog.martin.md to be served directly (not via redirect), the CI uses a
DNS TXT challenge token. A _git-pages-challenge.blog.martin.md TXT record was
created in Cloudflare containing the SHA-256 hash of the string
blog.martin.md <token> (domain, space, token).
The token is stored as PAGES_DNS_TOKEN in the blog repo’s CI secrets.
Added commit status for preview URLs
A preview commit status is posted after each deploy, making the preview URL
clickable directly from the PR view — no need to dig through CI logs.
Added automatic preview cleanup
A separate workflow (cleanup.yml) fires on pull_request: closed and sends a
DELETE to git-pages. Preview sites also auto-expire after 7 days via a
scheduled CI workflow (expire.yml) in the dc-git-pages repo.
Result
- No SSH keys or secrets needed for deployment
- Every PR gets a live preview with a clickable link in the PR view
- Preview sites are cleaned up automatically on merge/close, or expire after 7 days
- Theme and Hugo versions tracked automatically by Renovate
- Production deploys are DNS-validated, preview deploys are CI-verified
One less SSH key to worry about. Aaaaanyway, I’m out.