How this site is built, and why a new post is just a git push
Welcome to the first post. It’s about the site you’re reading, because the way it’s built says a lot about how I like to work: everything in Git, nothing done by hand twice.
I never log in to the server. There’s no WordPress admin, no database and no “please update your plugins” banner. If the site is broken, the fix is a commit. If a commit was bad, the fix is another commit.
Why not just use WordPress?
Nothing wrong with WordPress. But for a personal blog it means a PHP runtime, a database, plugins, and updates every other week. A static site is just files. Files don’t get hacked through a comment form, and a web server can serve them from a container the size of a large photo.
The trade-off is that there’s no editor in the browser. That’s fine for me. I already do everything else in an editor, and my editor lives in the homelab anyway. More on that in The laptop is just a screen.
The pieces
Hugo turns Markdown into HTML
Hugo is a static site generator: a program that takes a folder of Markdown files and a theme, and produces a folder of HTML. It’s a single Go binary, so there’s no Node or Python to install. The Hugo documentation is good, and you’ll need it exactly once.
A post is one folder with an index.md in it. Images for the post go in the
same folder. The top of the file is a small block of metadata, called front
matter:
---
title: "How this site is built"
date: 2026-09-03T10:00:00+02:00
summary: "One or two sentences shown in the post list and the RSS feed."
tags: ["hugo", "kubernetes"]
---
Below that it’s just Markdown. Headings, lists, code blocks, links.
A theme I wrote myself
The look of the site is a custom Hugo theme called koper. It lives in the
same repository. It’s deliberately plain: a system font, a monospace accent
for dates and tags, a light and a dark mode, and a lot of whitespace. There
are no external fonts and no ad networks. The only third-party script is
Swetrix, a cookieless page-view counter, and the site
works exactly the same when an ad blocker removes it.
Writing a theme sounds like a lot. It’s about ten small HTML templates and one CSS file. The hardest part was stopping.
A container that only knows how to serve files
The Dockerfile has two stages. Stage one runs Hugo. Stage two copies the result into an nginx image that runs as a non-root user on an unprivileged port.
FROM ghcr.io/gohugoio/hugo:v0.165.0 AS build
WORKDIR /src
COPY . .
RUN hugo --minify --gc
FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/public /usr/share/nginx/html
Hugo isn’t in the final image. Nothing is, apart from nginx and the HTML. That’s the whole attack surface.
GitLab CI builds and ships it
GitLab CI is the pipeline runner built into GitLab. The pipeline for this
site has four stages, each a job in .gitlab-ci.yml:
| Stage | What it does | When |
|---|---|---|
| build | Runs Hugo, keeps the HTML as an artifact | Every merge request and every push to main |
| test | Checks that every internal link points at a page that exists | Every merge request and every push to main |
| package | Builds the container image and pushes it to the GitLab registry, tagged with the commit hash | Push to main only |
| gitops | Writes that tag into my cluster repository | Push to main only |
The link check has already saved me once. A typo in a link isn’t a runtime error, so without the check it would happily go live.
You may wonder why I push straight to main. Don’t I use merge requests? Normally, yes. But the goal here was to automate publishing as far as it goes, so I made a deliberate choice: push to main, run the pipeline on every change. What if I push something broken? Then I fix it with the next commit, and in the meantime roll Kubernetes back to the previous image, which takes about a minute. For a one-person blog, that trade is worth it. For a team, or for anything with users who’d notice, I’d keep the merge requests.
Argo CD deploys it
This is the part I like most. The pipeline never talks to the cluster. It doesn’t have a kubeconfig, and it couldn’t deploy anything if it wanted to.
Argo CD is a GitOps tool: it watches a Git repository that describes what should run on the cluster, and makes the cluster match. My cluster is described in a second repository. That repository holds an Argo CD Application for the site, a small Helm chart with a Deployment and a Service, and a file with the image tag:
image:
repository: registry.koper.dev/graxo/koper.dev
tag: "827c7d1"
The last pipeline job clones that repository, replaces the tag with the new commit hash, commits and pushes. Argo CD sees the change and updates the Deployment. Two pods start with the new image, and the old ones go away once the new ones are healthy. The Argo CD docs explain the pattern well if you want to build the same thing.
Rolling back is editing that one line to an older hash. Every image ever built is still in the registry, so this takes a minute.
BunkerWeb in front
The pods only listen inside the cluster. The public entry is BunkerWeb, a web application firewall that also handles the Let’s Encrypt certificate and the redirect from HTTP to HTTPS. Adding the site was one YAML file with an Ingress and a few annotations, next to the other sites I host. The cluster itself runs on the hardware described on the Homelab page.
Adding a post, step by step
This is what I do when I want to publish something.
hugo new content posts/my-new-post/index.md
That creates the folder and the front matter from a template. I write the
post, run hugo server -D to look at it locally, and set draft: false.
Then:
git add content/posts/my-new-post
git commit -m "Post: my new post"
git push
About two minutes later it’s live. If I want a second pair of eyes first, I push a branch and open a merge request. The pipeline builds and checks it, but doesn’t publish until it’s merged.
That’s the whole publishing workflow. No FTP, no “deploy” button, no container build on my laptop.
Keeping it running
Maintenance is small, and all of it is a commit.
- Hugo version. Pinned once, in the pipeline. Bumping it is a one-line change, and the pipeline tells me if a template broke.
- nginx version. The base image tag in the Dockerfile. Same story.
- Security headers. The Content Security Policy is set in two places, nginx and BunkerWeb. They have to match, so both files have a comment pointing at the other.
- The theme. It’s in the repo, so there’s no upstream to track and nothing can change under me.
There’s nothing to patch on a Sunday evening. That was the point.
What’s next
The hardware all of this runs on is covered in five nodes, five jobs. After that come the Terraform and Ansible that keep those nodes in shape.
If you want to see the code, the repository is on my GitLab. The link is in the footer, and there’s more about me on the About page.