When Migration Costs Collapse

Last week, I changed the software running this website from Kirby CMS to Hugo. I migrated the site’s templates from Kirby’s template language to Hugo’s layouts in a couple of hours. I converted 900+ posts between platforms in 10 minutes. Finally, I did some optimizations to achieve an almost perfect PageSpeed result in half an hour.

AI collapsed migration costs. It made something previously tedious suddenly trivial.

It took me more than a week to move this site to Kirby CMS a couple of years ago. I like to tinker, but if I had to invest a week to migrate to Hugo, I wouldn’t have done it. I had reasons for the migration, but none critical.

Read along if you want the details!

* * *

WordPress and other platforms render webpages on the fly by taking the content from a database. A static site generator, in contrast, pre-renders all content generating all necessary files before the user actually visits the page.

Hugo is a static site generator (SSG). One of the advantages of static sites is that they require very little resources to host. Also, they are lightning fast. You don’t get the flexibility and possibilities you’ll get from a WordPress deployment, but lets face it: most sites use only a small percentage of what WordPress has to offer.

Why leave Kirby?

Kirby is a great CMS. It runs on PHP with a very small footprint. It has a nice web panel for creating content. While Kirby is not a static site generator, it doesn’t use a database. Content is stored as flat text files using Markdown for formatting.

I’ve gotten pretty acquainted with Kirby internals. I’ve built custom templates for different sections of my site. For example, the page which lists posts renders differently from the page that lists book reviews. Also, you can customize Kirby’s panel as much as you want, designing custom layouts for different kinds of posts. I guess that if you were using Kirby for publishing an online magazine, these features can make a real difference in your workflow.

Custom layout for book review posts in Kirby.
The corresponding post rendered using the custom book review template.

The downside is that for running Kirby, I need a Linux server. Cost is not the point. I use a minimal, very price-convenient Linode VPS instance. The point is the mental and actual time spent on maintaining the system.

My site uses three Kirby modules. One for generating the RSS feed. Another one takes care of syncing the site’s content with a GitHub repository1. A third module takes care of code highlighting so that code blocks appear neatly colored. None of these modules are part of Kirby, they are written by independent developers.

The Linux server is configured to automatically apply security patches every day. But Kirby doesn’t offer automatic updates. I could write a script to automatically do the updates. But updates sometimes break things. Other times features get deprecated. So I log into the server and do the updates manually every time I see a new version is available on the Kirby’s panel. Nothing complicated, but another thing I need to write down on my to do list.

The second reason for switching is that Kirby’s file format for content bothers me. In my workflow, I use text files wherever I can. As Obsidian’s creator Steph Ango says in FIle over app:

File over app is a philosophy: if you want to create digital artifacts that last, they must be files you can control, in formats that are easy to retrieve and read. Use tools that give you this freedom.

File over app* is an appeal to tool makers: accept that all software is ephemeral, and give people ownership over their data.

Kirby posts are stored as plain text files using a custom structured format. A post contains key-value pairs separated by lines of four hyphens. You can use Markdown for text, or Kirby’s more sophisticated layout blocks (which are also stored in text format). But every time I’ve tried to write a post in a text editor instead of Kirby’s web panel, it feels akward:

A post using Kirby's file format. Categories and tags appear after the post's text.

Hugo, in contrast, uses a very simple format for content: each file’s metadata—date, title, etc.—is contained at the top of the file in what Hugo calls front matter, which can be written in either YAML or TOML. Probably every programming language has more than a handful of libraries for parsing YAML and TOML files.

The same post using Hugo's file format. All metadata appears at the top fo the file.

Kirby’s data format is of course is still portable. It’s plain text, so you can write a script to convert it to whatever format you like. But why not use something standard like YAML, TOML, JSON instead of a proprietary format?

The migration process

The migration had two main fronts:

  • Converting my theme from Kirby templates to Hugo.
  • Converting each post to what Hugo expects for content.

Theme migration

The overall templates formatting—the HTML code and CSS defining the site appearance—remained exactly the same. I just had to replace the Kirby’s PHP for Hugo Go templates equivalent:

For example, in Kirby:

<?= $page->title()->html() ?>

to

{{ .Params.title }}

Templates that filtered posts and offered pagination or displayed my posts into a grid would be a little more trickier, but not too complicated.

I created an empty baseof.html file under Hugo layouts folder. I copied my main Kirby template as is to that file, and asked Claude Code to convert it a Hugo layout. I did the same for the home page template, for the different Kirby snippets—what Hugo calls partials—of my theme. Every time, I checked the outcome in the browser, and on the browser’s developer console. It worked almost flawlessly most of the time.

If something was not working, or I didn’t understand how Hugo was working behind the scenes, I pasted the URL into Claude Code and asked to explain the process Hugo was following to render the page. I learned a lot this way.

It took me around four hours to migrate my theme to Hugo, and have a site that, on the outside, looked exactly like my Kirby site.

Content migration

I needed two things to successfully migrate the content:

  • For each post, extract the metadata—date, title, categories, tags, etc.—from the Kirby-formatted file and write it in Hugo’s frontmatter format. (TOML in my case.)
  • Copy the content, which is in Markdown, converting any Kirby tags (like (:image …) either to Markdown—my preferred option—or to Hugo shortcodes.

My posts live all under the postsdirectory. I differentiate posts using categories. For example, a book review is rendered using a different template, because I want to show the book cover on the post, a rating if there is one, etc. So I needed to migrate this functionality to Hugo as well.

I asked Claude Code to write a migration script in Python, specifying the format I wanted for the post’s folders, to copy all the images, to specify the post’s cover image, convert Kirby tags, etc. My prompt included all the details I needed the script to perform. After some corrections, the script converted my 90+ posts. Claude coding the script took less than ten minutes. Performing the actual conversion took less than a minute.

I forgot a small detail, however.

I started zoia.org in 2005. For many years, the posts’ url included the date: /[year]/[month]/[day]/[title]. When I switched to Kirby, I began using the simpler /post/[title] format. To avoid linkrot, I configured Kirby so that existing posts could also be accessed in either format. I knew that you could use Hugo aliases to achieve the same result. However, you have to specify the url alias for each post:

+++
(...)
date = "2025-10-08"
aliases = [ "/2025/10/08/steve-jobs-on-smart-people", ]
(...)
+++

This should have been part of the migration script. I could have asked Claude to fix the script, copied the content from Kirby again… but I said, let Claude decide. I explained Claude Code the problem, and asked it to solve it. It proposed to write a bash script to add the aliases. Again, worked flawlessly.

Hosting

While I could have written a script to copy my new Hugo site to my Linux server each time I published a new post. Rather than maintain my server setup, I decided to use Cloudflare Pages. To publish a post, I git push the changes to a GitHub repository, and Cloudflare automatically takes the content, runs Hugo and Pagefind on their servers, and publish the final rendered site.

One of the advantages of using Cloudflare is that they take care of the server’s security settings. In my Linux server, I spent some time fine tunning the correct headers and Content-Security Policies (CSP).

I use a form on my site to subscriptions to my newsletter. I added a custom header in a _headers file in Hugo’s static directory so that the page was allowed to send the data to the list processor when the user hit the subscribe button:

/*
  Content-Security-Policy: form-action 'self' https://*.list-manage.com

Further adjustments

At this point, my new Hugo site looked exactly the same as my Kirby site, and it had exactly the same content. From the outside, you wouldn’t be able to tell if I was running Kirby or Hugo. I could have just published it as it was. However, it was the opportunity to fix some things that I had neglected in my Kirby blog.

In Kirby, my nav menu bar entries were hard-coded into the template. My fault, not Kirby’s. So I asked Claude Code to do the necessary changes and it configured my Hugo theme to pull the menu options from the Hugo configuration file, writing the actual entries to the configuration.

Also, with the help of PageSpeed Insights, I improved the loading speed by including and minimizing the CSS stylesheet inline for production and optimizing the images on the main page, which was a small change. For accessibility, I added labels that were missing in some buttons and links.

This was the final PageSpeed score:

PageSpeed results

Kirby offers a native search function. Static sites usually don’t, because there is no code running on the server. One solution is to generate an index of all entries, and use Javascript on the browser to show the search results based on those indexes.

After some research, I settled for Pagefind for search. Pagefind both indexes all your posts and provides a client-side Javascript interface for doing the actual search and display the results. In 15 minutes I had it set up and running on the website.

What Changed (and What Didn’t)

AI didn’t make the decisions for me. I still had to:

  • Choose Hugo over the dozen other static generators

  • Understand what I actually wanted (faster loading, better formats)

  • Debug when something didn’t work

  • Decide that Pagefind was the right search solution for a static site

What AI did was eliminate the activation energy. That week of tedious conversion work that would have stopped me? Gone. The grunt work of writing migration scripts, converting templates, adding 1000 aliases? Trivial.

This changes the calculus for experimentation. How many projects sit in your “someday, if I had time” list? How many tools do you stick with not because they’re ideal, but because switching is too expensive?

That cost just dropped to near-zero. I’m not saying you should migrate your blog. But if you’ve been wanting to try a new framework, reorganize your notes, or experiment with a different approach—the barrier isn’t weeks of work anymore. It’s a Sunday afternoon.

What becomes possible when the cost of changing your mind is ten minutes instead of a week?


  1. If you want more technical details, for Github to work and sync the Kirby’s site contents, it needs to run as the same user as the webserver. So for any operation on the server to work, I have to prefix it with sudo -u www-data↩︎

Hugo Static Site Generator Kirby AI AI for coding Claude Code

Join my free newsletter and receive updates directly to your inbox.