This Blog Now Runs on Hugo: No WordPress, No Kubernetes, and Stable HTTP/3

This blog no longer runs on WordPress and K3s but as a static site on AlmaLinux 10. What changed, what went wrong in testing, and why HTTP/3 is back on.

Browser showing apt-upgrade.me, with the WordPress logo in a wastebasket marked decommissioned, a Migration Completed arrow, the Hugo logo and a Live badge

🌐 Also available in: Deutsch · Français · Español

In short: This blog no longer runs on WordPress and Kubernetes. It now consists of finished files that nginx serves from a hardened AlmaLinux 10. In the Hugo experiment a few days ago nothing was decided yet; on November 6, 2026 I made the switch. All posts in four languages keep their old addresses, Grafana and the hardening stayed, PHP, the database and the cluster are gone. And HTTP/3, which I switched off only this week, is running again.

This post covers what changed, what went wrong in testing, how performance moved, and what the switch means for my daily routine and my backups.

Table of Contents

🧒 For non-IT readers: what is different?

Until now, a program assembled each page from a database whenever someone requested it, and a cache kept the result for a while. Now I build all pages once in advance on my own computer and upload them finished. The server only hands out files. No program runs for visitors, there is no login, and there is nothing an attacker could execute with a manipulated request.

The takeaway: The site looks the same. What changed is what runs behind it, and that is far less today.

🔄 What changed

BeforeNow
Operating systemAlmaLinux 9 with K3sAlmaLinux 10 without Kubernetes
Web serveringress-nginx in the clusternginx mainline 1.31.8 directly on the host
ContentMariaDB and a media folderFiles in a private Git repository
Runtime for visitorsPHP-FPM, Redis, page cachenone
LoginWordPress admin with 2FAGrafana only, publishing by SSH key
SearchWordPressPagefind, runs in the browser
HTTP/3offon
MonitoringPrometheus, Grafana, Loki, Monitunchanged, minus the cluster metrics

I also reorganized the firewall. Forwarding is off entirely, since without a pod network the host has nothing to route, and rp_filter is strict again. Everything that existed only because of K3s is gone: NodePort chains, pod exceptions, cluster audit rules. fail2ban no longer has a WordPress login rule; requests for /wp-login.php now count as an attack attempt. nginx may not open outgoing connections any more, except to Grafana on loopback.

The takeaway: A rebuild is the rare chance to actually delete rules. Many of them were only still there because they had once been needed.

📦 The new repository structure

One repository became three:

  • www_hugo (public): Ansible for the blog server. A single ansible-playbook line rebuilds the machine from nothing.
  • www_hugo_content (private): Posts, images, theme, build and deploy scripts.
  • nextcloud_k3s (public): The Nextcloud part continues there, unchanged, on K3s.
  • www_k3s: The old repository is archived and stays available for reference.

The content is private because Git forgets nothing. An image I later remove for legal or personal reasons would stay retrievable in the history of a public repository forever.

In the experiment post I warned against copied roles, because every security fix then has to be made twice. That remains the price of this solution. Against it stands scripts/compare-roles.sh, which shows the differences between the shared roles. The split also saves work: the blog repository shrank by about 34,800 lines.

The takeaway: A separate repository does not solve everything. It trades the rule “every change must fit both stacks” for the duty to carry lessons across actively.

✍️ Posts in a repository instead of a database

A post is now a Markdown file. My routine looks like this:

# Write the post, preview it locally, then:
tools/build.sh                          # builds all pages and the search index
tools/deploy.sh <host>                  # upload, then atomic switch
tools/deploy.sh <host> 10022 rollback   # if something is wrong

A repeat deploy took 18 seconds in testing. The switch itself is just a repointed symlink, and the last five versions stay on the server. The key can only upload, activate and roll back. The server refuses anything else, and I tested that.

There are real downsides too, and I will not gloss over them:

  • No more writing in the browser or on my phone. Posts are created on my computer.
  • No media library and no automatic image optimization by a plugin. Images go through the repository and the build.
  • Scheduled posts do not appear by themselves; that would need its own timer.
  • No Yoast hints. The template now generates the search engine tags at every build, but nobody warns me if a description is missing.

The takeaway: I am trading writing comfort for operational simplicity. That fits a one-person blog; for an editorial team it would be the wrong call.

🚀 Performance: old against new

Measured with Chrome, median of seven runs each. The old values come from the live blog, the new ones from the test machine with no visitors and a self-signed certificate. So this is not yet a fair final comparison.

MeasurementWordPressHugo
Phone, post: first content visible2.54 s0.71 s
Phone, front page: first content visible2.54 s0.59 s
Desktop, post: fully loaded451 ms140 ms
Post: requests4213
Post: data455 KB235 KB

Right after the switch I measured on the real server, with a real certificate and from outside:

Measurement on the real serverWordPressHugo, HTTP/2Hugo, HTTP/3
Phone, post: first content visible2.54 s0.80 s0.75 s
Phone, front page: first content visible2.54 s0.64 s0.58 s
Desktop, post: fully loaded451 ms386 ms315 ms
Desktop, post: data455 KB178 KB178 KB

Phones gain clearly; on desktop the gap is smaller than on the test machine. This is not a long-term value yet: the server was fresh and without normal load.

The first measurement also exposed a bug in my new theme: the slider loaded five full-size images at once, even on phones where it is hidden, and the image actually visible came last. After the fix, the largest element of the front page appears on phones after 1.29 instead of 2.25 seconds, and the data dropped from 545 to 292 KB there and from 1.22 MB to 606 KB on desktop. The table above shows the state after that.

Then the view from other continents: total time for the same post over HTTP/2, median of five Globalping probes each, fetched once. There is no before value for this, because I never measured that way under WordPress. So I cannot say whether it got faster there.

RegionTotal time
Europe0.26 s
North America0.56 s
Asia1.02 s
South America1.06 s
Africa1.15 s
Oceania1.72 s

The server is in Germany, and static files do not change the distance. More would take a CDN, which I deliberately do not use.

The takeaway: The biggest gain does not come from faster hardware but from needing fewer files and no program run.

🎢 HTTP/3: off this week, on now

A few days ago I explained in a long post why HTTP/3 stays off here: after an nginx reload, a worker kept alive by one long connection still holds the shared UDP sockets, and about one in eight QUIC connections hangs. The upstream fix was called quic_bpf, and I considered the matter closed, with a certain dignity even.

Then came the move, and without an ingress controller I could choose the nginx version freely for the first time. So one more try, starting with the calm, proven version to be safe:

nginx with quic_bpfHanging connections under reloads
1.26no improvement
1.30.640 of 1,000
1.31.8 (mainline)0 of 2,370

So the proven path was the wrong one. The solution needed mainline, plus the capabilities CAP_BPF, CAP_PERFMON and CAP_NET_ADMIN for a service I otherwise restrict as far as I can, and its own SELinux module so the BPF program may load at all. Add worker_shutdown_timeout 30s so an old worker does not stay forever.

Finally I wanted to see it with real Chrome. Result of the first run: 300 of 300 pages failed, each within a fraction of a second. A brief scare, then the explanation: for QUIC, Chrome does not accept a self-signed certificate even with the general ignore switch, so the fault was in my test setup. With the key hash pinned and under constant fire, a reload every five seconds plus an open download, 298 of 300 pages arrived over HTTP/3, with a median of 236 ms and the slowest at 1.3 s. Without reloads it was 300 of 300. No page hung in either run. So the two aborts probably come from the reload, and that is rare in daily use: a deploy does not trigger one at all.

The takeaway: A finding is a statement about a version. “Does not work” was right this week and no longer is. And when a test fails 100% of the time, check the test first.

🔧 What the from-zero test found

Before wiping the real server, I had the test machine freshly installed and ran the playbook from zero. That paid off immediately, because these errors would otherwise have caught me during the real migration:

  • The reboot that was not one: The first run reboots the server so that the SSH port and firewall become active together. The command ran with async: 1 and began with sleep 2. Current ansible-core 2.21 ends the job after one second and still reports changed. The playbook then waited five minutes for a port that never opened.
  • The empty PID file: The check nginx -t creates an empty /run/nginx.pid when nginx is not running yet, with the SELinux type var_run_t. The service in httpd_t may then not open the file and does not start. This happens on every first install.
  • A fail2ban that did not exist yet: The firewall handler restarts fail2ban, but on a fresh machine it is not installed at that point. This only showed in the second real run from zero; before that, the firewall was already in place and hid it.
  • What WordPress did on the side: Comparing with the live blog showed that robots.txt, the old sitemap addresses and twitter:card were missing. Yoast had provided all of them.
  • Locked out by my own security: The domain is on the HSTS preload list. So my Firefox would not let me open the self-signed test site, not even after deleting all data. The rule is built into the browser.
  • The mysteriously slow test machine: With the same specs as the blog server, it first ran many times slower. After the next reinstall the difference was gone, and the from-zero run took about 12 instead of over 30 minutes. I do not know the cause; I suspect the host machine. It was not the playbook, since I measured every minute during the run.

After the fixes, the check run showed changed=0.

The takeaway: A run on an existing machine proves little. The run from zero is the real test, and it has to happen before the real thing.

🚧 What went wrong during the real migration

Despite all the testing, the real thing did not go smoothly, and that belongs here. The blog was unavailable on November 6 from about 18:10 to 19:52, far longer than the half hour I had planned.

  • The missing directory: Let’s Encrypt could not be tested on the test machine, so that step ran for the first time on the real server. The hook directory only appears on the first certbot run; the playbook wanted to write into it earlier and aborted. That was quickly fixed. The run then continued on port 10022 and got a valid certificate immediately.
  • Locked out by myself: I wanted to keep the old SSH host keys so other machines would still recognize the server. They had mode 640 with the group ssh_keys, and the group got lost in the copy. sshd refuses such keys and did not start. I had restarted it without sshd -t, and my only way in was gone. The rescue was a chmod 600 through the provider’s console. The test machine did not show this because its keys already had 600.
  • Empty Pressure panel: psi=1 only takes effect after a reboot, and on AlmaLinux 10 /proc/pressure has its own SELinux type proc_psi_t, which my node_exporter module did not know.
  • No swap: I simply had not thought of it during the rebuild. Without K3s nothing speaks against it, so there is now a small swap file.

On the plus side: all 592 old addresses of posts and pages still answer, the site’s checksum matches, and a sitemap trap surfaced in time: it contained tag pages marked noindex.

The takeaway: A test only proves what it touches. Before restarting a service your own access depends on, always run its config check.

🛡️ Is this uncritical?

The attack surface has become much smaller. There is no PHP, no plugins, no database and no WordPress login any more. Only the Grafana login remains on the server, protected by a country filter, an upstream login and fail2ban. The regular security updates for WordPress, plugins and the cluster are gone. But the risk has not vanished, it has moved:

  • GitHub account: It now holds all the content. It only replaces the WordPress 2FA if it is well protected itself.
  • Build machine: Whoever takes it over can change the site with the deploy key, but not the server.
  • Certificate: Because of HSTS preload, no visitor can click away a certificate warning. If renewal fails, the blog is unreachable at once. Monitoring has to report that early.
  • HTTP/3 on mainline: I deliberately run a development line with extended rights. In return, a single variable switches HTTP/3 off again.
  • The move itself: The only way back was the full backup. That is why I checked it beforehand and saved the certificates separately.

The takeaway: Static sites are not automatically secure. The risk moves from the server to accounts, keys and certificates.

💾 Backup: less data, different questions

I used to back up mainly the database and media, or the blog was gone. Now the website itself is derived: it can be rebuilt from the content repository at any time. The same goes for the server, which the playbook builds. What matters is what cannot be recreated:

  • Content repository: The actual source. It lives on GitHub and additionally in the homelab backup.
  • Certificates: So that a rebuild does not depend on the next successful request.
  • SSH host keys: Otherwise every connection raises an alarm after a restore.
  • Visitor statistics: They cannot be regenerated from anything else.
  • Vault password: Without it the playbook is of little use in a rebuild, so keep it separately.

The test paid off here too. Without rsync -H, my new backup would have multiplied the hard-linked versions, and rdiff-backup would also have versioned aborted backups, recording files as deleted. Both are fixed. On a freshly installed test machine, the restore then took 16 seconds, the checksum across all 18,837 files matched the build, and the SSH host keys were the old ones again. The old WordPress backup including the database dump stays stored separately.

The takeaway: A backup only counts once the restore has really run. That almost everything can now be rebuilt makes it easier, not unnecessary.

🎯 The lessons

  1. Measure first, then believe. That applies to “Hugo does little for readers” as much as to “HTTP/3 does not work”.
  2. The test from zero finds what every run on a grown machine hides.
  3. A plugin like Yoast does more than you notice. Before switching it off, compare its output line by line.
  4. When you remove a component, remove its exceptions too: firewall, audit rules, alerts.
  5. Security shifts, it does not disappear. After the rebuild, what counts is who holds the key and the account.
  6. Always sshd -t before restarting sshd, and keep a second way in ready.

I will add how this holds up in daily use and under normal load after a few days of operation.

Some text and images on this site are AI-assisted or AI-generated. Details