Migrating from ghost to hugo in 2023


I’ve been running this blog on a self-hosted ghost platform ever since the very first post. Over time, ghost served me well - and this is not a decision against ghost, but for a simplified site setup.

I’ve been eyeing at hugo for some time now. As it builds a static webpage, there are tons of hosting options (free and paid). To me, this provides the benefit of being able to decomission the server I used to run the blog on.

In this blogpost, I’ll outline the steps I took to migrate the existing content to the new blog.

Initial setup

To get started, I used ghostToHugo - a fork of the original ghostToHugo project, to convert all posts in Ghost to Hugo templates.

This is as easy as creating an export from the ghost admin panel (Settings -> labs -> "Export your content") and downloading the resulting .json file. We then run ghostToHugo -p ~/mysite export.json - which creates a hugo project for us.

Next, I took an existing simple theme and configured this for my new hugo project

git clone https://github.com/hugo-sid/hugo-blog-awesome.git themes/hugo-blog-awesome

we then add this theme to our hugo.toml file as theme = "hugo-blog-awesome".

At this point, we already have an initial page up and running, which we can start with hugo server.

It’s worth pointing out that links between posts won’t work at this point, nor will images render correctly.

Inspecting one of the posts, it becomes immediately clear why - ghost’s export added a __GHOST_URL__ in front of all links. [link](__GHOST_URL__/duplicity_remote/)

We can fix this pretty easily by removing all occurances of __GHOST_URL__/ (for the above case, this leaves [link](duplicity_remote/)).

Images have a similar behavior - though slightly more complicated, as ghost stores them as follows: ![onedrive_oauth1](__GHOST_URL__/content/images/2023/08/some_image.png)

As I like to keep the post and corresponding image together, I used the following approach (shown on the example of a fictionary post hello-world.md):

  • Create a directory next to the hello-world.md file.
  • Move the file into the directory.
  • Move corresponding image files into the directory (i happened to have the original files still in a backup repository).
  • Replace image sources.
mkdir content/post/hello-world/
mv content/post/hello-world.md content/post/hello-world/index.md

mv ../imagesource/hello-world.jpg content/post/hello-world/

We can now replace the link to be a relative link: ![onedrive_oauth1](__GHOST_URL__/content/images/2017/10/some_image.png) -> ![onedrive_oauth1](some_image.png).

We obviously have to repeat this for every blogpost with images. The resulting structure is a mix of directories (posts with images) and standalone posts (posts without images).

Update Post header

Out of the conversion, all posts had a header as follows:

date = 2023-08-03T11:30:00Z
description = ""
draft = false
slug = "hello-world"
title = "hello world"
summary = "Hello world blog post"
tags = ["personal"]

While this is a good first step - it’s not ideal. The description was empty in all cases - however many posts had a summary.

A quick look at the Lighthouse benchmark revealed that the posts were missing a “description” field (your experience may vary depending on the selected theme).

As a quick fix, I’ve simply copied the content of “summary” to “description” - as that seemed to be fitting in most instances.

Final heading:

date = 2023-08-03T11:30:00Z
description = "Hello world blog post"
draft = false
slug = "hello-world"
title = "hello world"
summary = "Hello world blog post"
tags = ["personal"]

URL changes

My existing ghost blog was using url’s as follows: https://blog.xmatthias.com/hello-world/. Hugo by default inserts a /post in the middle, so the url becomes https://blog.xmatthias.com/post/hello-world/. That’s not a problem, but requires some attention, which means we have to

  • Add redirects - ideally as 301 (hugo-style redirects will hurt SEO).
  • Migrate url’s on a comment platform (in my case, disqus).


As i’m going to deploy my hugo page on cloudflare pages, I can use the redirects feature - which is as simple as a _redirects file in the static directory (/static/_redirects)

I have the following rules.

/rss/ /post/index.xml 200
/rss /rss/ 301
# Redirect from old post addresses
/hello-world /post/hello-world/ 301
/hello-world/ /post/hello-world/ 301
# ... (continued for all posts)

The first 2 lines are there to ensure the RSS url remains the same, available under blog.xmatthias.com/rss/.

All posts will receive a permanent redirect (301) - which will allow SEO to continue to work, as the new page is treated identically to the prior page.

Disqus / comment system

For disqus, there’s a few “migration tools”. Right after putting the new page live, i verified that all redirects work - and then tried to use the “Redirect Crawler”. Unfortunately, it didn’t yield the expected results - which led me to the URL mapper.

This takes a plain csv file with from,to format - so for our sample post, it looked as follows:


This format is very similar to the one used in _redirects - and required only some changes. Saving and uploading the file gave me a confirmation box (so i could check my redirects).

After confirming that this was working, the comments started to imediately pop up on the new page.


In total (including learning hugo, and the most timeconsuming task, picking a theme), this took me about 3-4 hours over the course of a few days.

This was sufficient for me to put the blog out into the wild - though this wasn’t all - and after changing the blog engine, I did some further updates (Theme adjustments, adding security headers). This will however be a topic for another blog post.

comments powered by Disqus