I Made This…

The Setup

This blog runs on Hugo. The source lives in a self-hosted Forgejo instance. When I push a commit, a Forgejo Actions runner builds the site and drops the output where the busybox web server can find it. The busybox webserver is a container running a naught but a single command. Traefik handles HTTPS and reverse proxy for all those bits.

The Pieces

Hugo

Hugo is a static site generator that uses Markdown (along with TOML). I use the risotto theme because I am on of those nerds (I have customized risotto and probably will again). The source repo has the content, the theme, and a hugo.yaml for config. Running hugo --minify produces a public/ directory full of HTML, CSS, and nothing else. No admin panel, no database, just a shiny website with no cruft.

Forgejo

Forgejo is a gitea fork with actual community governance because I care about stuff like that

The source lives in a Forgejo (a gitea fork with actual community governance because I care about) container on docker in my homelab. Forgejo has its own GitHub compatibel Actions implementation. Just prop a workflow file in .forgejo/workflows/ and it picks it up. Similar to GitHub but no Micro$oft in sight.

The Runner

The Forgejo runner is a separate container that lices in the same compose file and polls Forgejo for jobs and executes them. I have it configured with a docker label, so it spins up a fresh Docker container for each job. It is on the same network as Forgejo for ease of use (I can just use container names because I am lazy).

The Workflow

Here’s the whole thing:

name: Local Hugo Build
on: [push]

jobs:
  build-and-deploy:
    runs-on: docker
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          submodules: true

      - name: Setup Hugo
        uses: https://github.com/peaceiris/actions-hugo@v3
        with:
          hugo-version: 'latest'
          extended: true

      - name: Build Hugo Site
        run: hugo --minify

      - name: Deploy to Busybox Volume
        run: cp -R ./public/* /deploy/

That last step copies the built site into /deploy/. Which brings us to the fun part.

The Busybox Thing

Traefik is my reverse proxy. It handles TLS termination and routing for everything in my homelab. What it doesn’t have is a built-in static file server — it routes requests to backends, it doesn’t serve files itself. So you need something to actually serve the HTML.

The obvious choices are nginx or Caddy. Nginx is way to heavy for this bullshit little site and I left Caddy in the rearview so I didn’t have to keep editing YAML files for every little thing. What I landed on is busybox because the whole freaking busybox image is about 4MB. It includes a tiny HTTP server invoked like this:

httpd -f -p 80 -h /www

That’s the entire web server. -f runs it in the foreground (so Docker can manage the process), -p 80 sets the port, -h /www sets the document root. That’s it. No mucking around with sites-available and symlinks or writing YAML files, just a tiny webserver that does one thing.

The Docker service looks like this:

services:
  hugo-site:
    image: busybox:latest
    command: httpd -f -p 80 -h /www
    volumes:
      - ./public:/www:ro

The ./public directory is mounted read-only as /www. Traefik proxies wintermute.willkill.us to busybox on port 80, handles the SSL certs, and automatically forces HTTPS. It’s all rather clever even if I do say so myself. This is really my favorite part of this. Busybox is amazing for what I needed, it’s literally the most minimal way to serve the blog and I think that a static site should have an exteremely minimal webserver. No shade to those who want to muck about with other webservers that they aleady have (except maybe people that us Apache outside of enterprise deployments, and even then I’m giving you side-eye), but to me this feels like kismet.

The Clever Bit

The runner container and the busybox container both bindmount the same directory. When the runner builds the site it writes directly to the public/ as the bind mount is that same so it’s instantly served by busybox. I commit, for instance this post, then push to the repo for this site. On push forgejo triggers the ruuner, and if I didn’t screw up the Markdown or forget to set the post status to draft: false in the TOML, and suddenly there’s a new post. The next page load after the job finishes gets the new content. That’s the whole deploy pipeline.

Tiny Annoyances

  • If you load the page mid-deploy you might get a partially updated site. For a stupid blog that gets two whole visitors a month, I really don’t care.
  • busybox httpd doesn’t support range requests, cache headers, brotli, or a bunch of other things “better” webservers do, but again: my blog, two visitors, don’t care.

Random Thoughts

Hugo can be a real pain in the ass but for me it had the theme I wanted so I beat it into submission and now it’s painless workflow. I really don’t feel like getting into setting up the Forgejo container, it’s highly likely most people will never bother self hosting a GitHub alternative, and I am lazy so I didn’t expound on that. This whole post is really just me bragging about the whole busybox http server bit and using a post on my blog to that, because it’s my blog and I do what I want.

 

No Gods, No Masters

There is no ethical consumption under capitalism


Hugo, Forgejo Actions, and a busybox one-liner walk into a bar

By Wintermute, 2026-06-23