# Moving a 13-year-old blog from WordPress to Hugo: static files instead of PHP

Source: https://blog.xsk.in/en/moving-blog-from-wordpress-to-hugo/
Published: 2026-11-02 · Categories: IT, Linux


*Translated from [the Russian original](/it/pereezd-bloga-s-wordpress-na-hugo/) with AI and reviewed by the author.*

This blog was actively maintained from 2013 to 2018, and in that time it collected 120 notes on Linux, nginx, OpenWRT and Zabbix. After that I never found the time to update it, so for the next 8 years it just sat there, still running on an ancient VM with Debian 6 and an equally old WordPress. I knew it was a ticking time bomb. Recently it started ticking indecently loud. Below is how the move went and what I stepped on along the way.

## Where it was

In October, CVEs with remote code execution in PHP came out. The old server ran PHP 5.6, which will never get fixes, so the blog was turned into static files in a hurry: WordPress went into a private network, the Simply Static plugin exported HTML, and a script uploaded it to an S3 bucket that served it. Not that it worked badly, but keeping PHP, MariaDB and a pile of tribal knowledge about deployment around for one post in eight years is not our way. And these days a shared knowledge base is worth having somewhere anyway.

## Where it went

I compared Hugo, Zola, Astro and Eleventy. A bucket has no server-side logic, and Hugo covers what that requires: post URLs are set in front matter (`url:`), old slugs go into `aliases`, RSS can live at the old `/feed/` address, and every page can have a second version as raw markdown.

Hugo is also a single binary with no npm tree, and a blog with no interactivity has no use for a yearly Node major. I picked the theme from three candidates by building my own posts with each and settled on PaperMod: the text comes first, and it does not need cover images, which I don't have. The whole site builds in under a second.

## The converter

I didn't want to pull in a third-party binary for a single run, so I skipped the ready-made `wp2hugo`. My own converter from the WordPress export (WXR) came out at 360 lines of Python with no dependencies. It turns simple HTML into markdown and leaves tables and definition lists as inline HTML, because markdown would only mangle them. The multi-line `<code>` blocks I used for commands back in 2013 become proper code blocks. Line breaks inside paragraphs are kept by `hardWraps` in goldmark, the way `wpautop` used to do it.

## Keeping the URLs intact

All the old URLs of the form `/it/<slug>/<ID>` stayed the same, but an S3 bucket has its quirks.

- For a URL without a trailing slash the bucket answers 302 to the slashed one, and WordPress linked without it. So every page is also uploaded under a key without the slash, and the answer is a straight 200.
- The bucket ignores query parameters and cannot do a 301 based on them. Old links like `/?p=57` are caught by a small script on the home page; the "ID to URL" map is built from front matter at build time.
- I kept the RSS GUIDs in the WordPress format (`?p=ID`) purely for backward compatibility (or because Claude suggested it).
- A non-existent URL gets an honest 404 with a search box. If the visitor does nothing, after seven seconds they are taken to the home page.

After the rollout I checked 230 URLs, with and without the slash, and all of them returned 200.

## For people and for AI

Every article now has a markdown version (`…/index.md`), the site has an `llms.txt`, and under every post there is a "Read with AI" block. It copies a prompt with a link to the article, or opens it right away in ChatGPT or Claude. I borrowed the idea from [pimenov.ai](https://pimenov.ai/articles/sait-teper-mozhno-chitat-s-ii/) (in Russian). The prompt asks the model to apply the text to your own task rather than retell it. The main button copies the prompt, because the `q` parameter in ChatGPT and Claude is undocumented and may stop working.

## Comments: what was lost and what was found

The blog's comments lived in three places. WordPress itself held nothing but spam. From 2017 the comments ran on isso on the old server, and here's the fail: its database was in `/var/lib/isso`, while my archive of the server covered `/etc`, `/home`, `/var/www` and the MySQL dumps. The server was recently shut down and deleted, and the isso comments went with it. I don't remember anything useful there, so no great loss. Disqus, which hosted the comments in 2013–2016, gave me a full export: 68 live comments, and every one of them found its post.

New comments now live in Remark42 on my own server: you can sign in with Telegram, GitHub or anonymously, and notifications come to Telegram. Remark42 imports from Disqus out of the box, but it ties comments to the URL in the export, and that export mixes URLs from three eras of the blog. Before the import a small script rewrites them, matching posts by title.

## What I removed

There were no visit statistics for three years: the old Google Analytics stopped accepting data back in 2023. According to Yandex Webmaster and Search Console, the blog mostly lives on OpenWRT (MAC cloning, UCI, moving the root filesystem to a USB stick). There is almost nothing to delete; the old notes will just get an "archive" label.

The only thing I deleted was the "I Was the Stig" series. It was an amateur translation of chapters from someone else's book, search engines didn't show it, and the copyright question doesn't go away. Ads, a lightbox, jQuery and the Twitter plugin went too.

## What I took away

1. A server archive doesn't end at `/etc` and the databases. Whatever applications keep in `/var/lib` is data too.
2. Until a backup has been restored at least once, you don't know whether you have one.
3. DNS, the certificate, the comments deployment and the backup are now honest IaC. In six months I won't remember what I did by hand. The AI will find it from context.

Comments are working again; they are below. They are separate from the comments on the Russian version.

*The original draft was prepared with AI from the migration notes and almost entirely rewritten by the author.*

