Octopress is a great blogging engine/framework, and I was happy with it for several years, but recently I discovered a new one - Hugo.

Octopress to Hugo

I liked it so much that I decided to move my blog to it.

First time I heard about Hugo was this 1Password’s blog post telling about their migrating to Hugo from WordPress. And then I heard about some other teams started using Hugo too, so I decided to try it myself.

Octopress vs Hugo

Mostly I wanted to compare Hugo with Octopress as the latter has some problems:

  1. Octopress is no longer being developed;
  2. The environment/infrastructure (Ruby and gems) is quite complicated and fragile. Every time I was moving between computers, I had problems setting up the environment for building and deploying: all of those gems dependencies and their mysterious errors, my god;
  3. Live rebuilding takes 8+ seconds, even if you just changed one character;
  4. Essentially, Octopress is a fork of someone else’s blog, which might be a bit confusing and not very convenient to work with;
  5. The community is rather small, and sometimes it’s quite challenging to find a solution for various problems.

And as it turned out after a brief evaluation, Hugo is indeed better than Octopress:

  1. It is actively developed and updated;
  2. The whole thing is just a single self-contained executable file;
  3. Live rebuilding takes fractions of a second;
  4. Nice and helpful community.

So I started the migration process.

Migration from Octopress to Hugo

I won’t describe the whole process of setting up Hugo from scratch, so refer to the official documentation and other sources (like this one about directory structure).

Instead I will list the concrete steps I took and share the solution for problems I encountered.

Keeping the old URLs

Of course I wanted all the URLs to remain available as my blog is referenced from quite a few places on the internet.

The URLs have the following structure:


So I added this value to config.toml:

    blog = "/blog/:year/:month/:day/:filename/"

Renaming the posts

That’s actually just an intermediate step for organizing posts into bundles.

Two things to be done here:

  1. Change the extension from .markdown to .md;
  2. Delete the date from the name.

So instead of


it should be


You can automatically do that with almost any decent file manager. Since I’m on Mac, I used ForkLift.

Changes in posts files

There are several things that need to be deleted or changed in Octopress post files in order for those to be parsed correctly by Hugo:

  1. I decided not to differentiate posts by layouts (layout: post), so that line has to go;
  2. The line that enables/disables the comments (comments: true) is not needed either;
  3. I want categories from Octopress to become tags in Hugo, so those need to be replaced;
  4. Summary divider in Hugo can only be this hardcoded value - <!--more--> - no customization possible, so I had to replace the divider from Octopress (<!-- more -->) with it.

I used sed to automate the process:

find . -type f -iname "*.md" -exec sed -i '' -e '/layout: post/d' {} \;
find . -type f -iname "*.md" -exec sed -i '' -e '/comments: true/d' {} \;
find . -type f -iname "*.md" -exec sed -i '' -e 's/^categories:/tags:/' {} \;
find . -type f -iname "*.md" -exec sed -i '' -e 's/<!-- more -->/<!--more-->/' {} \;

Reorganizing the posts

Hugo has this nice feature - Page Bundles - when the post file and all its media are stored in the same folder. Unfortunately you cannot have HTML pages within bundles, which is a bit frustrating, so I had to put those into the /static folder (replicating the corresponding post path).

For example, here’s how a post file is named and organized in Octopress:

├── source
   ├── _posts
      ├── 2018-02-17-build-qt-statically.markdown
   ├── images
      ├── build-qt-statically
         ├── dynamic-vs-static.png
         ├── install-qt-msvc.png
         ├── qt-configure.png

And here is a Page Bundle for the same post in Hugo:

├── content
   ├── blog
       ├── build-qt-statically
          ├── images
             ├── dynamic-vs-static.png
             ├── install-qt-msvc.png
             ├── qt-configure.png
          └── index.md

For that posts need to be renamed and reorganized accordingly:

  1. Create a new folder for every post;
  2. Name the folder after the post name;
  3. Put the post file inside that folder and then rename the file to index.md;
  4. Put the post images to the images folder within this folder.

To automate that I created the following Python script.

But I couldn’t automate moving images as I had those stored in folders with different names. For the same reason I couldn’t automate changing references to those images in post files, which resulted into several days of a manual work.

Syntax highlight

Syntax highlight comes out of the box and I recommend using the default shortcode for it.

I didn’t risk to automate the process of replacing the syntax highlighting I used in Octopress, which meant yet again several days of manual labor.


So categories from Octopress are tags in Hugo. Actually, you can have both categories and tags, but I decided that tags are just enough for me.

For tag pages to work properly I had to specify the following in config.toml:

disableKinds = ["taxonomyTerm"]
preserveTaxonomyNames = true

    tag = "tags"

And here’s how to list all the tags with post counters (/themes/YOUR-THEME/layouts/_default/baseof.html):

<div class="sidebar-section">
  <ul style="list-style-type:none; padding:0; margin:0;">
      {{ range $.Site.Taxonomies.tags.ByCount }}
      <li><a href="/tags/{{ .Name }}">{{ .Name }} ({{ .Count }})</a></li>
      {{ end }}

Working with Hugo

Here I will tell about a couple of useful things I discovered about Hugo.


Shortcodes is a super convenient Hugo feature. It is kinda mini templates for certain things like images or syntax highlight blocks. Relying on shortcodes you control all their occurrences in your posts, so when you change the style or structure of a shortcode it will change in all the posts.

For example, here’s my shortcode for images:

{{ $altText := .Get "alt"}}
{{ with $.Page.Resources.GetMatch (.Get "name") }}
    <img class="image-post" src="{{ .RelPermalink }}" alt="{{$altText}}"/>
{{ else }}
    <code style="color:red;">Error: could not find image "{{ .Get "name" }}"</code>
{{ end }}


{{< image name="images/name.png" alt="Description" >}}

And here’s one for videos:

{{ with $.Page.Resources.GetMatch (.Get "name") }}
    <video controls loop class="video">
        <source src="{{ .RelPermalink }}" type="video/mp4">
    <p class="video-fallback">If video doesn’t play in your browser, you can download it <a href="{{ .RelPermalink }}">here</a></p>
{{ else }}
    <code style="color:red">Error: could not find video "{{ .Get "name" }}"</code>
{{ end }}


{{< video name="video/name.mp4" >}}

Auto-generated table of contents

Yes, it just woks (almost) out of the box. Simply put {{< toc >}} into your post and it will generate a table of contents with all the anchors and links automatically.

For that to work you need to create a shortcode /layouts/shortcodes/toc.html with the following content:

{{ .Page.TableOfContents }}


Archetypes help with creating new posts. Here’s an example of my post bundle archetype:

├── default.md
└── post-bundle
    ├── images
       └── .gitkeep
    └── index.md

.gitkeep is just an empty file to force creating of the images folder.

The contents of index.md:

title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
tags: [some]
draft: true


[ here goes the shortcode for {< toc >} which I cannot put here as it is because it will be parsed into the actual table of contents ]

# Header

Now I can create new posts with the following command (from the root folder):

hugo new --kind post-bundle blog/ololo-some-post

Custom 404 page

Custom 404 page can be specified via /layouts/404.html:

{{ define "main"}}
    <main id="main">
       <img class="image-post" src="/404.jpg" />
       <p style="text-align: center;">No such page, mate!</p>
{{ end }}

By the way, /404.jpg means that local image path is /static/404.jpg.


For generating RSS you need to add a special layout for that, which can be customized as well.

A bit sad news is that the link to your RSS feed will be /index.xml and apparently there is no way to make it to be /rss.xml.

Another problem is that it’s not trivial to make use of CDATA, so you could provide a markup for your post summaries for feed readers to parse it into something nice. But thanks to this guy I’ve managed to do that.

Here’s my /layouts/rss.xml:

<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:georss="http://www.georss.org/georss">
    <title>{{ .Site.Title }}</title>
    <link>{{ .Permalink }}</link>
    <description>Recent content of {{ .Site.Title }}</description>
    {{ with .Site.LanguageCode }}<language>{{.}}</language>{{end}}
    {{ with .Site.Copyright }}<copyright>{{.}}</copyright>{{end}}
    {{ if not .Date.IsZero }}
    <lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>
    {{ end }}
    {{ with .OutputFormats.Get "RSS" }}
        {{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
    {{ end }}
    {{ range .Pages }}
      <title>{{ .Title }}</title>
      <link>{{ .Permalink }}</link>
      <pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
      <guid>{{ .Permalink }}</guid>
      {{ `<description><![CDATA[` | safeHTML }}New post: {{ .Title }}]]></description>
      {{ `<content:encoded><![CDATA[` | safeHTML }}<h2>{{ .Title }}</h2>{{ .Summary }}]]></content:encoded>
    {{ end }}

Yet another problem is that images and videos in my posts have relative URLs which are not allowed in RSS feeds. I haven’t figured out yet how to transform those into absolute ones in summaries.

Robots.txt and sitemap

Sitemap is generated automatically and robots.txt is controlled via /layouts/robots.txt:

User-agent: *

Sitemap: {{ .Site.BaseURL }}sitemap.xml


To start a local server for previewing run:

hugo server

Note that if some of your posts have draft: true then those won’t be rendered. To include those too add the option -D:

hugo server -D

As I said, pages regeneration happens almost instantly, which is a killer-feature in comparison to Octopress.

To generate your blog/website pages for deployment simply run the the command:


Results will be generated to the /public/ folder. And in case of GitHub Pages that’s the place where you need to create a repository. But unlike Octopress there is no deploy command, so you will be the one performing the actual deployment via regular git push.

Oh, in order for links and styles to work properly you need to set the baseURL parameter in config.toml:

baseURL = "https://retifrav.github.io/"