← writing

My Blog Now Ships From My Homelab!

If you’ve given GitHub’s status page a quick glance recently, you’ll immediately see that it’s been going through it. Every outage is a small reminder that maybe we shouldn’t rely too much on single points of failure we can’t directly control.

So, having been bitten several times by intermittent outages and service disruptions, I figured a small personal win would be to migrate the repository for this site to the homelab. Now, it lives on my own Forgejo forge and the builds run on my own Woodpecker cluster. The only things that leave the network are static build artefacts that get pushed up to Cloudflare Pages.

The switch was surprisingly straightforward and took less than 30 minutes overall.

Before anyone reaches for the comment box, no, you don’t need a homelab for any of this. Forgejo and a Woodpecker cluster ( server and at least one agent ) will happily run on a spare mini PC, an old laptop, that Raspberry Pi you bought with the best of intentions or even as a single Docker Compose file running on the machine you use every day.

How complex can this possibly be?

The first useful thing I did was ask myself what GitHub was actually doing for me when it comes to managing this site. After some mulling about, I could only really list the following:

  • Hosting a git repository.
  • Hosting the comment threads, via giscus Discussions.

That’s pretty much it. I don’t use Actions and GitHub alone never built this site. Cloudflare Pages watched the main branch and did the building itself. This is something I covered back in My Blog Publishing Setup. GitHub’s one job was holding a bunch of markdown files.

That’s a strange thing to accept as a single point of failure.

The new shape of things.

The replacement looks like this:

  • I push to Forgejo, which lives inside my homelab network.
  • Forgejo fires off a message to the Woodpecker Server over a webhook. Also inside the network.
  • The Woodpecker Server assigns the job to an available Woodpecker Agent, which clones the repo, builds the site with Hugo and pushes the result to Cloudflare Pages with wrangler.
  • Cloudflare serves the files at the edge, exactly as before.

The part I want you to notice is the direction of travel. Under the old setup, Cloudflare reached into GitHub to pull my source. Under the new one, nothing reaches in at all. The forge and the CI server accept no traffic from the internet. The only packet that crosses the network boundary is an outbound upload of compiled static assets.

That reversal is the entire security model which was pretty much the point. What looks like “the old way but more steps” allows me to control every aspect of the pipeline up to just before we ship the build artefacts. A push-based deploy means self-hosting your forge requires exposing exactly nothing. No reverse proxy gymnastics, no VPN for the webhook and no port forwarding required.

If my homelab bursts into flames mid-afternoon ( a real possibility as I do live in Queensland, Australia ) Cloudflare keeps serving the last deploy and nobody visiting the site would ever know. That being said, nobody actually does visit this site, so…

A small intermission.

A short aside, because it’s a question I’d ask. My internal services hang off a domain I genuinely own, but resolving it depends on where you’re standing. Inside the network a locally hosted 3-node Technitium cluster runs the hosted zone and the router sends all DNS requests through it. Initially, I used this for network-wide adblock, which works an absolute treat. It also means the forge and the CI cluster resolve to their internal addresses. Outside the network, the public zone points at a landing page hosted elsewhere.

Same hostname and two completely different worlds depending on which side of the wall you’re on. Split-horizon DNS is nothing new, but it’s what lets me use real TLS-friendly hostnames for internal services without those services ever being reachable from the internet.

If I did decide to open some services to the outside world, like my Navidrome server, I could use an overlay network like Netbird to provide friends and family access via a familiar naming convention without ever opening a port on the router.

The part Cloudflare doesn’t clearly advertise.

A Pages project is either connected to a git provider or it accepts direct uploads. Never both. As long as your project is wired to GitHub, wrangler pages deploy will be rejected and you will get screamed at.

Disconnect the git integration in the project settings and the project quietly converts to “Direct Upload” mode. It keeps its name, its custom domain and its whole deployment history. It just stops watching a repository and starts waiting for you to hand it those delicious files. While you’re in there, make sure the production branch is properly specified: main in my case. This matters because Wrangler tags each upload with a branch name. An upload whose branch matches the production branch goes live on the site. An upload under any other name becomes a preview deployment with its own URL instead.

After that, the deploy is one command requiring two secrets:

  • An API token scoped to Cloudflare Pages: Edit
  • Your Cloudflare account ID.

Both go into the Woodpecker Server as repository secrets.

Finally, our favourite! YAML.

The whole thing is just one file named .woodpecker.yml that squats in the repository root.

YAML
when:
  - event: [push, manual]
    branch: main

clone:
  git:
    image: woodpeckerci/plugin-git
    settings:
      partial: false
      tags: true

steps:
  build:
    image: hugomods/hugo:debian-git-0.163.3
    environment:
      HUGO_ENV: production
      HUGO_ENVIRONMENT: production
    commands:
      - git config --global --add safe.directory '*'
      - ./bin/changelog
      - hugo --gc

  deploy:
    image: node:22-slim
    environment:
      CLOUDFLARE_API_TOKEN:
        from_secret: cloudflare_api_token
      CLOUDFLARE_ACCOUNT_ID:
        from_secret: cloudflare_account_id
    commands:
      - >-
        npx --yes wrangler@4 pages deploy public
        --project-name=wilhelm-codes
        --branch=main
        --commit-hash="${CI_COMMIT_SHA}"
    when:
      - event: push

Only build off main.

I test design changes and new entries locally, so a push to any other branch doesn’t start a pipeline.

We want a full clone, not a shallow one.

This site’s changelog page renders the git log and Hugo’s enableGitInfo reads commit dates for every page. This is something I wrote about in A Changelog That Builds Itself. CI systems love shallow clones and a shallow clone here means I can’t convey the full historical picture on the page. Worse, enableGitInfo derives every article’s dates from that same truncated log, so they all come out wrong besides. Setting partial: false makes Woodpecker fetch the entire history.

The build step is a stock Hugo image.

Nothing is installed on the agent itself unless you count Docker image pulls. The image pins the exact Hugo version, ships the extended edition and includes git and bash. Both of which the changelog script that generates the data requires. The safe.directory line exists because the cloned workspace is owned by a different user than the one inside the container and git these days refuses to read a repository it thinks you stole.

The deploy step is a stock Node image.

npx pulls Wrangler at run time. Wrangler then reads the two secrets from the environment and pushes public/ up to Pages and that’s pretty much it. Dependencies are snagged and cached at build time, which means nothing to maintain on the agents. Either step’s version gets bumped by editing one line.

I already have distribution hosted in the homelab for my local Docker images. I could easily configure it to support pull-through caching, so I don’t keep having to pull directly from Docker Hub every time a build agent’s local cache expires.

Complete parity with what Cloudflare’s own build pipeline was doing, except now I can read every line of it and make it do whatever I need.

I did run into some speed bumps along the way.

For the record, it took only three tries to get the pipeline to a “green” state.

Third time's a charm.

In order of discovery:

  • The plain version tags of the Hugo image are the extended edition. The reg- prefix means regular and none of them include git unless the tag says git. The tag you guess first ( exts-<version> ) doesn’t exist. It’s an odd convention, which is why it threw me off.
  • Woodpecker substitutes ${VAR} expressions in the YAML before the shell ever runs. Anything it doesn’t know becomes an empty string. My safe.directory "${CI_WORKSPACE}" quietly became safe.directory "". Hence the asterisk / glob in the YAML.
  • The changelog script opens with #!/usr/bin/env bash and the default Hugo images are Alpine. env: can't execute 'bash' is the whole error message you get, so I just went with the Debian variants instead which include bash out of the box.

None of these are hard problems, but worth pointing out.

I’m not deleting the old repository… completely.

The GitHub repository still exists if only to provide a place for the giscus integration to store comments. It offloads most of this functionality to GitHub’s Discussions feature. Delete the repo and every comment ever left on this site goes with it; all 2 of them.

It no longer needed the full source history to do that job either. It was ported on over to my local forge with Forgejo’s native GitHub repository migration feature. So, I force-pushed the whole history into a black hole, leaving a single commit containing only a README. The remaining husk also has a second job coming; it’ll eventually host the companion code for tutorial content, so readers can clone examples without ever touching my forge.

Each service kept exactly the job it’s uniquely good at. Cloudflare serves files from the edge better than my house ever could and no fucking way am I publicly opening ports on a router. I’d rather lick the rim of a pub toilet. GitHub Discussions gives commenters an identity they already have. Everything else now lives at home.

In closing …

Was any of this necessary? For a static blog that deploys in under a minute, probably not. The old setup worked fine and if yours does too, keep it. I’m not here to convince anyone.

There’s a special kind of satisfaction in watching a pipeline run end to end on hardware you can physically point at, knowing the only cloud dependency left is the one actually earning its keep. The next GitHub outage can happen without me. At least for this project anyway.

Comments