Migrating From Jekyll to Hugo, deploying with Github Pages

Moving away form Jekyll. Import is easy, but tackling important details (conserve links to article, conserve comments, migrate some jekyll liquid template, theme tweaking deployment) has been challenging sometime.


When started this blog in 2010 I went like a lot other to Wordpress as this was the one of the most common and easy way to write and publish something, then I got pissed at Wordpress as it was a pain to maintain and to write code, not to mention the speed, it was bad, then Github announced Github Pages, this was a revelation : articles written in pure Markdown, no database or server to maintain, fast writing, fast page thanks to static files. There were quite a few things that were icky like theme modifications, and some Wordpress plugins that were simply not there, but once done it was a robust machine.

Now would like to write using Asciidoc.

So what’s wrong with Jekyll with Github Pages.

  • Github Pages for good reasons limit what can be executed on their server, that means limited jekyll plugins, and with limited plugins comes limited theme choice.

  • Generating the files locally is hazardous, because you need to keep the same configuration as the one used by Github Pages, I was fist installing Ruby packages, but that was resolved when I switched to a docker images.

  • You basically to fork or copy the theme files in the repo, making difficult to track change from upstream while keeping your local changes.

  • I want to write in Asciidoc as it offers more way to layout information in the article.

I could have chosen to simply use a Jekyll Asciidoc plugin, generate the static files and push them to Github in an automated way. There’s now decent alternatives to consider, like Hugo, to be fair it’s the only thing I tried because of it’s native asciidoctor support. And it has other interesting aspects:

  • Just one executable

  • Really fast to generate (with markdown content), not that jekyll was too slow for me, but the speed is noticeable.

  • Theme (from another repo) and content are well separated

  • Templating and overrides is superior in my opinion

  • Support more than markdown if external tool are available like asciidoctor.

  • Multi lingual content supported

This long article won’t talk much about Asciidoc, but much more about the migration phase as there was a lot of rocks on the road. Let’s start by the first step: Importing Jekyll blog.

Importing from Jekyll

❯ hugo import jekyll --log ../bric3.github.io .
Congratulations! 42 post(s) imported!
Now, start Hugo by yourself:
$ git clone https://github.com/spf13/herring-cove.git ./themes/herring-cove
$ cd .
$ hugo server --theme=herring-cove
❯ git clone https://github.com/achary/engimo.git ./themes/engimo
Cloning into './themes/herring-cove'...
remote: Enumerating objects: 40, done.
remote: Counting objects: 100% (40/40), done.
remote: Compressing objects: 100% (27/27), done.
remote: Total 4367 (delta 14), reused 26 (delta 12), pack-reused 4327
Receiving objects: 100% (4367/4367), 4.77 MiB | 6.88 MiB/s, done.
Resolving deltas: 100% (2270/2270), done.

Let’s run the server to see that basic import works

❯ hugo server --theme=engimo

                   | EN
  Pages            | 294
  Paginator pages  |   4
  Non-page files   |   0
  Static files     | 106
  Processed images |   0
  Aliases          | 127
  Sitemaps         |   1
  Cleaned          |   0

Built in 170 ms
Watching for changes in /Users/bric3/private/bric3-hugo/{archetypes,content,data,layouts,static,themes}
Watching for config changes in /Users/bric3/private/bric3-hugo/config.yaml
Environment: "development"
Serving pages from memory
Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender
Web Server is available at http://localhost:1313/ (bind address
Press Ctrl+C to stop

But nothing’s there.

What I missed is that I actually needed to copy the config.yaml|toml configuration example file from the theme’s exampleSite folder. Eventually depending on the theme, this file format may be yaml or tool format, my theme one was a toml file, so I had to remove the yaml configuration file that was generated by the Hugo import; the generated content was pretty thin so that was easy to port over the config.toml.

cp themes/hyde-hyde/exampleSite/config.toml .
rm config.yaml

Also, I noticed it’s easier to switch theme by not using the command line flag --theme=<theme> and instead use the configuration file config.yaml|toml instead by using the theme field. And simply run hugo server.

theme = "hyde-hyde"

By the way here’s what my directory structure looks like, before I perform some changes to fix some issues

│  ├──post
│  │  ├──2010-01-24-hello-world.md
│  │  ├──2010-02-09-a-propos-de-joda-time.md
│  │  ├──2010-02-12-une-fuite-memoire-beaucoup-de-reflection-et-pas-de-outofmemoryerror.md
│  │  ├──2010-02-16-les-collections-par-google-comment-sy-retrouver.md
│  │  └──37 unlisted
│  └──_gen
│     ├──assets …
│     └──images
│  ├──assets
│  │  ├──0x304D-ki-hiragana.png
│  │  ├──0x6C23-chi.png
│  │  ├──add_new_jsdk.png
│  │  ├──application-gc.png
│  │  └──20 unlisted
│  ├──CNAME
│  ├──css
│  │  ├──highlightjs.piperita.scss
│  │  ├──jquery.mmenu.all.css
│  │  ├──simplebar.css
│  │  └──style.scss
│  ├──favicons
│  │  ├──android-chrome-144x144.png
│  │  ├──android-chrome-192x192.png
│  │  ├──android-chrome-36x36.png
│  │  └──26 unlisted
│  ├──js
│  │  ├──jekyll-search.js
│  │  └──jquery.mmenu.min.all.js
│  ├──search.json
│  └──serve.sh
   │  ├──archetypes …
   │  ├──assets …
   │  ├──CHANGELOG.md
   │  ├──exampleSite …
   │  └──7 unlisted
      ├──_assets …
      ├──_sites …
      ├──archetypes …
      └──12 unlisted

A long way to fix the small issues

Right now finding the theme that works for your blog is one of the most time-consuming tasks, as you need to identify which feature you need and how to migrate, I’ve been trying several themes to see how they work, one aspect that I didn’t quite like is that you need to adapt for each theme the configuration file.

Especially the section [params] which is used by the theme templates, and each theme differ well enough for that to be a cumbersome process. At this point it’s really a good thing to read the Hugo configuration documentation.

Fixing the post permalinks

My current blog expose posts with the following path


The default configuration exposed these links as


After some search I found these can tweaked with the permalink setting, I don’t know exactly why but this was only taken in account after the [params] section.

    posts = "/:year/:month/:day/:title/"

Which made the post accessible to this url.


At some point I might be considering the use of Hugo aliases and change the urls of the post to something like


Actually I got this wrong, Jekyll’s permalink configuration /:year/:month/:day/:title has a small unusual thing, when reading the doc :


Title from the document’s filename. May be overridden via the document’s slug front matter.

So when setting /:year/:month/:day/:title/ in Hugo config.toml the permalink are not exactly what I hoped them to be, this actually the title from the front matter of the article. I had to resort to the technic used in this blog Convert Jekyll to Hugo Permalinks to keep the same permalinks.

cd content/posts
for f in *.md;
  base=`basename "$f" '.md' | cut -f 4- -d '-'`
  gsed -i "s/title:/slug: $base\ntitle:/" "$f"

gsed is gnu-sed from brew install gnu-sed on MacOs. Because newlines don’t work with the BSD sed .

Adding custom pages

My Jekyll site had some other pages, those where located in the _pages of my Jekyll site. In order to understand how the site works, I needed to read content organization documentation. The only thing I had to do is copy these files over the content directory of the Hugo site.

│  ├──cool-stuff.md
│  ├──post
│  │  ├──2010-01-24-hello-world.md
│  │  ├──2010-02-09-a-propos-de-joda-time.md
│  │  ├──2010-02-12-une-fuite-memoire-beaucoup-de-reflection-et-pas-de-outofmemoryerror.md
│  │  ├──2010-02-16-les-collections-par-google-comment-sy-retrouver.md
│  │  └──37 unlisted
│  └──whoami.md

It’s even possible to do a layout like that.

│  ├──cool-stuff
│  │  └──index.md
│  ├──post

I preferred the easy way in regard of these page content. Also, I wanted my post in the posts folder instead of post, I just had to rename the folder and that was it.

Finally, in order to access the content I needed to add the menu entries, like that. I didn’t need to read the menu documentation for it to work, but there’s more stuff possible when going over it.

        identifier = "post"
        name = "Posts"
        title = "All posts"
        url = "/posts/"
        weight = 1

    # ...

        identifier = "whoami"
        name = "Who Am I ?"
        title = "About me"
        weight = 4
        url = "/whoami/"

    # ...

Accents (diacritical marks) in some url

This will be appreciated for languages that have diacritical marks like French. So defining the permalink with /:year/:month/:day/:title/ lead to use the post title for the link, however some have accent that are then url-encoded :


I didn’t find it in the Hugo doc, but in the Hugo issue tracker, it’s possible to add in the config.toml, this setting is not part of any section.

removePathAccents = true

Will then make the urls as follows :


Migrate Jekyll liquid template to Hugo

  • {{ site.baseurl }} for images, simply removed as website base url starts with /assets too.

  • {% comment %} …​ {% endcomment %} ⇒ comments are tricky, if it’s a shortcake, this is part of the markdown generation and using html comment may work <!-- {{< shortcode >}} -→. But for notes taken during articles, in the end I created my own shortcode draftNotes.

    {{ if .Site.Params.DisplayDraftNotes }}
    {{ .Inner | markdownify }}
    {{ end }}
  • {% if …​ %} {% endif %} was used to comment stuff, it’s replaced by draftNotes shortcode.

  • {% gist gist_id %}

  • {% raw %} {% endraw %} there’s nothing to do here for me, this directive disables Jekyll processing for text having {{ …​ }} which Jekyll interpreted as Jekyll template.

  • {:.alternate} ⇒ used by kramdown to apply a CSS style, it can be removed

I had to read the shortcodes documentation and look at how to create my own shortcode.

They are just as other Jekyll liquid template :

{{ amazon_product_image_link | replace:'$asin$','0132350882' | replace:'$size$',img_size }}

These can be easily changed to a shortcode. Hugo doc for example showcase the figure shortcode:

{{< figure src="/media/spf13.jpg" title="Steve Francia" >}}

I’ve crafted my own simple shortcode for Amazon {{< amzn "B07XW76VHZ" >}} :

<a href="https://www.amazon.com/exec/obidos/ASIN/{{ $itemId }}/" class="amazon-shortcode" target="\_blank">
        {{- if eq (len .Params) 1 -}}
        <img src="https://images-na.ssl-images-amazon.com/images/P/{{ $itemId }}.jpg"/>
        {{- else if eq (len .Params) 2 -}}
        {{- $imageId := .Get 1 -}}
        <img src="https://images-na.ssl-images-amazon.com/images/I/{{ $imageId }}.jpg"/>
        {{- end -}}

By the way I have found this blog post interesting to read to craft my own shortcodes.

Migrate inline HTML in the markdown content

The following HTML elements will be omitted in the rendered page

<div class="table-wrapper" markdown="block">
| Markdown table |
<!-- raw HTML omitted -->
<!-- raw HTML omitted -->

Hugo 0.69 uses Goldmark to render markdown, and it has a setting allow inline HTML

  defaultMarkdownHandler = "goldmark"
      unsafe = true

But for safety, and self documentation, I’d prefer to migrate those to shortcodes as well, like wrapTable for this one.

<div class="table-wrapper">
{{ .Inner | markdownify }}

I encountered an issue however when the table itself has shortcodes. This break table rendering. The only way to support that is to use/create shortcodes for opening and for closing the div in this cases.


The Hugo import command copied over a few Jekyll stuffs that are not anymore useful. So git gives me these files for example that could be removed. I included the favicon as well, as I wanted some refresh.

	renamed:    css/highlightjs.piperita.scss -> static/css/highlightjs.piperita.scss
	renamed:    css/jquery.mmenu.all.css -> static/css/jquery.mmenu.all.css
	renamed:    css/simplebar.css -> static/css/simplebar.css
	renamed:    css/style.scss -> static/css/style.scss
	renamed:    favicons/README.md -> static/favicons/README.md
	renamed:    favicons/android-chrome-144x144.png -> static/favicons/android-chrome-144x144.png
	renamed:    favicons/favicon.ico -> static/favicons/favicon.ico
	renamed:    js/jekyll-search.js -> static/js/jekyll-search.js
	renamed:    js/jquery.mmenu.min.all.js -> static/js/jquery.mmenu.min.all.js
	renamed:    search.json -> static/search.json
rm -rf static/{css,js,favicons,search.json}

However, the CNAME file has been moved to static folder, which is wrong, let’s put it back at the root of the git repository.

renamed:    CNAME -> static/CNAME

Remove unused Hugo themes

rm -rf themes/slick

Adapt my .gitignore

# Created by https://www.gitignore.io/api/hugo
# Edit at https://www.gitignore.io/?templates=hugo

### Hugo ###
# Generated files by hugo

# Executable may be added to repository

# End of https://www.gitignore.io/api/hugo

Paginate post list page

I found this issue #18 on Hyde-hyde theme, however this issue referred to an old version of the theme which has since been updated.

One thing that is interesting and useful is how Hugo allows overriding parts of a theme. The themes are located in the ./theme folder, e.g.

│  └──default.md
│  └──scss
│     ├──hyde-hyde …
│     ├──hyde-hyde.scss
│     └──4 unlisted
│  ├──config.toml
│  ├──content
│  │  ├──about.md
│  │  ├──portfolio …
│  │  └──posts …
│  ├──layouts
│  └──static
│     └──img …
│  ├──main.png
│  ├──mobile.png
│  ├──portfolio.png
│  ├──post.png
│  ├──screenshot.png
│  └──tn.png
│  ├──404.html
│  ├──_default
│  │  ├──baseof.html
│  │  ├──list.html
│  │  └──single.html
│  ├──about
│  │  └──single.html
│  ├──index.html
│  ├──partials
│  │  ├──footer …
│  │  ├──header …
│  │  └──9 unlisted
│  ├──portfolio
│  │  └──list.html
│  └──shortcodes
│     ├──fig.html
│     ├──kbd.html
│     └──3 unlisted
│  └──_gen
│     └──assets …
│  ├──apple-touch-icon-144-precomposed.png
│  ├──css
│  │  ├──hugo-toc.css
│  │  ├──hugo-toc.css.map
│  │  ├──hyde-hyde.css
│  │  └──9 unlisted
│  ├──favicon.png
│  └──img
│     ├──hugo.png
│     ├──menu-close-dark.svg
│     ├──menu-close.svg
│     └──2 unlisted

In order to override parts of the theme it’s possible to copy the file in the root of the Hugo site (following the same directory structure). For post lists, I identified two files in the theme directory :

  • layouts/partials/page-list/content.html

  • layouts/partials/posts-list.html

These files need to be copied over the root of the Hugo site with the same relative path. And modify them as needed.

--- 1/themes/hyde-hyde/layouts/partials/page-list/content.html
+++ 2/layouts/partials/page-list/content.html
@@ -1,6 +1,4 @@
 <span class="section__title">{{ .Title }}</span>
 <ul class="posts">
-    {{ with .Data.Pages }}
-        {{ partial "posts-list.html" . }}
-    {{ end }}
+    {{ partial "posts-list.html" . }}
--- 1/themes/hyde-hyde/layouts/partials/posts-list.html
+++ 2/layouts/partials/posts-list.html
@@ -1,6 +1,7 @@
-{{ range . }}
+{{ $paginator := .Paginate (where .Pages "Type" "in" "posts") }}
+{{ template "_internal/pagination.html" . }}
+{{ range $paginator.Pages }}
     <a href="{{ .RelPermalink }}" {{if .Draft}}class="draft"{{end}}>{{ .Title }}</a>
       {{if not .Date.IsZero}}
       <time class="pull-right hidden-tablet">{{ .Date.Format (.Site.Params.dateformat | default "Jan 02 '06") }}</time>
@@ -8,3 +9,4 @@
 {{ end }}
+{{ template "_internal/pagination.html" . }}

Here I’m using the Hugo internal template for pagination but one can image using a custom template. The .Paginate directive was taken from pagination doc, however the doc have a slight issue, the where query needs to be where .Pages “Type” “in” “posts” keyword.

However, I noted that other lists do not render anymore, for example /tags or /series, this is because the file we modified affect all list based page, search where the page-list/content.html partial is used raises the general list.html located there themes/hyde-hyde/layouts/_default/list.html. Since I want the pagination only for posts at this time, I just have to create a structure like this in my root Hugo site.

│  └──posts
│     └──content.html

I created a posts folder in the layouts directory and in the layouts/partials, then I moved the file layouts/partials/page-list/content.html to layouts/partials/posts/content.html and merged the content of layouts/partials/posts-list.html replacing the Hugo function {{ partial "posts-list.html" . }}, and I removed this file as it breaks other taxinomies. Finally, I had to create the layouts/posts/list.html file, that invokes {{ partial "posts/content.html" . }}.

Tweak landing page number of posts

Here I needed to modify the index layout to only display the last X recent post.

--- 1/themes/hyde-hyde/layouts/index.html
+++ 2/layouts/index.html
@@ -4,7 +4,7 @@

 {{ define "content" }}
   <div class="post-list">
-    {{ $paginator := .Paginate (where .Site.RegularPages "Type" "in" site.Params.mainSections) }}
+    {{ $paginator := .Paginate (first .Site.Params.landingLastPosts (where .Site.RegularPages "Type" "in" site.Params.mainSections)) }}
     {{ range $paginator.Pages }}
       {{ if .Draft }}
         {{ .Scratch.Set "draftPage" true }}

This trick is not current as you need to wrap the query part with the first function (first .Site.Params.landingLastPosts <query>), and I added landingLastPosts in the params section.

    landingLastPosts = 5

Comments with Disqus

So Hugo supports Disqus comments, but the thing is that site is generated by a theme and the theme may or may not handle comments as you wished, so it’s necessary to look at how it’s done in Hugo and in the theme depending on the requirements.

For my Jekyll site, my comments had to be migrated from a Wordpress engine, posts on Wordpress have different Disqus identifier that is now specified in the front matter, and this identifier was passed to Disqus script configuration. Here’s my Jekyll website relevant part:

<script type=“text/javascript”>
    function disqus_config() {
        this.experiment.enable_scroll_container = true;
        this.page.url = “{{ site.cname }}{{ page.url }}”;  // Replace PAGE_URL with your page’s canonical URL variable
        this.page.identifier = ‘{% if page.disqus_identifier %}{{ page.disqus_identifier}}{% else %}{{ site.cname }}{{ page.url }}{% endif %}’; // Replace PAGE_IDENTIFIER with your page’s unique identifier variable
    var disqus_shortname = "{{ site.disqus_account }}"; // required: replace example with your forum shortname
    /* * * DON'T EDIT BELOW THIS LINE * * */
    (function () {
        var dsq = document.createElement('script');
        dsq.type = 'text/javascript';
        dsq.async = true;
        dsq.src = '//' + disqus_shortname + '.disqus.com/embed.js';
        (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);

This script was crafted manually to pass identifier coming from the old days when it was powered by Wordpress. Looking at the Hugo template for Disqus, I know there are other elements of the page configuration that are actually passed over to Disqus.

{{with .Params.disqus_identifier }}this.page.identifier = '{{ . }}';{{end}}
{{with .Params.disqus_title }}this.page.title = '{{ . }}';{{end}}
{{with .Params.disqus_url }}this.page.url = '{{ . | html  }}';{{end}}

So that’s interesting but life isn’t that simple as theme may override as well internal template by their own; the theme I chose uses a template that doesn’t use the Hugo internal to initialise Disqus script, my them file is located at layouts/partials/page-single/comment/disqus.html. THa leaves me no choice but to to override this partial comment template of the theme layouts/partials/page-single/post-comment.html, it this I merged the Hugo internal template, and I tweaked the template of the script initialization to behave the same as my old Jekyll site. The most important bit is here :

this.page.identifier = {{with .Params.disqus_identifier }}'{{ . }}'{{else}}{{ printf "'%s%s'" .Site.Params.disqusIdentifierBaseURL .RelPermalink | safeJS }}{{end}};
this.page.title = {{with .Params.disqus_title }}'{{ . }}'{{ else }}'{{ .Title }}'{{end}};
this.page.url = {{with .Params.disqus_url }}'{{ . | html  }}'{{else}}{{ printf "'%s%s'" .Site.Params.disqusIdentifierBaseURL .RelPermalink | safeJS }}{{end}};

Also for reasons that I don’t understand, the .RelPermalink / .Permalink Hugo functions escape the URL’s slashes with backslashes when the template function is placed inside a single quotes of the JS script. The only workaround was to use printf "'%s%s'" then pipe to safeJS function.

Then just in case the admin is available here https://<you disqus short code>.disqus.com/admin/settings/general/.

Now permalinks for posts are defined as /:year/:month/:day/:slug/, the slug is a special entry that is computed by Hugo or set in the front matter of the content page. And the permalinks are used as Disqus identifiers. In order to have stable permalinks and Disqus identifiers if changing the generation backend, it’s better to write it down.

I name my posts with a date then a string that is likely the title of the post, e.g. 2020-04-01-manage_dotfiles_with_chezmoi.md. With the permalink structure in mind I need my slug as the part of the filename after the date.

So let’s use the Hugo archetypes that will allow us to create a new post. It can be simply done by creating a file archetypes/posts.md with a content like:

authors: ["brice.dutheil"]
date: "{{ .Date }}"
language: en
draft: true
tags: ["cool"]
slug: "{{ .Name | replaceRE "\\d{4}-\\d{2}-\\d{2}-(.*)" "$1" }}"
title: "{{ .Name | replaceRE "\\d{4}-\\d{2}-\\d{2}-(.*)" "$1" | title }}"

Example content

The slug then becomes the part of the file without the date. Note that this archetype file can be easily duplicated as an Asciidoc file by adding a posts.adoc.

However, for now it’s necessary to write manually at the beginning of the filename the ISO-8601 date, meaning we have to write :

hugo new posts/2020-04-14-migrating-from-jekyll-to-hugo.md

Automate deployment on Github Pages

As I’m using Github Pages to host this site, and it only supports Jekyll based website for automatic site generation. This is nice to avoid only technical maintenance for me, but with Hugo this is a different story. I need to actually generate the website, then push it to a special branch. Let’s try to do that manually before going automatic.

Manual deployment of the static files

So that’s what I had hoped. Yet I got this message in the repository settings.

Github Pages repository settings

I tried to create an empty gh-pages branch. Here’s some useful git command by the way:

# create a new empty branch (from your current branch)
true | git mktree | xargs git commit-tree | xargs git branch gh-ages
git push --set-upstream origin gh-pages:gh-pages

But the settings page still insists that it should be done on master, not quite the same as mentioned in the GitHub Pages doc. I decided to drop this approach and removed the gh-pages branch for now.

Finally, by trying things out, if there’s an index.html file on master, then the branch files will be used to serve as static content. Following this clue, removing all files in master but CNAME, it is enough to publish Hugo generated file from the ./public folder to the master branch.

Due to this Github Pages constraint the Hugo directory structure and site files are in another branch like hugo-sources that is configured as the default branch of this repository.

Automate deployment

For that let’s use Github Actions, it’s possible to declare what needs to be done in a yaml file, and it appears the market place has everything I need to do that

  • The Hugo action that install Hugo and configures Hugo

Let’s configure the same version as the local one

❯ hugo version
Hugo Static Site Generator v0.69.0/extended darwin/amd64 BuildDate: unknown

Version 0.69.0, and it is important to activate the extended flag as well.

The only thing that we need is a deployment key as documented here.

ssh-keygen -t rsa -b 4096 -C "$(git config user.email)" -f gh-pages -N ""

Going there, and add the public deployment key, check Allow write access :

cat gh-pages.pub | pbcopy

Then add the secret key here named ACTIONS_DEPLOY_KEY.

cat gh-pages | pbcopy

Don’t commit these files. But it may be useful to store them securely.

It’s necessary configure the publication branch to master, apparently using master is indeed necessary for a repository like <username>/<username>.github.io, see this section.

Note that the deployment actions seems to remove all files, but we need the CNAME file fortunately it’s possible to configure the cname option.

This gives us the following configuration in .github/workflows/<name of the workflow>.yml :

name: GitHub Pages

      - hugo-sources

    runs-on: ubuntu-18.04
      - uses: actions/[email protected]
          submodules: true
          fetch-depth: 0

      - name: Setup Hugo
        uses: peaceiris/[email protected]
          hugo-version: '0.69.0'
          extended: true

      - name: Build
        run: Hugo —minify

      - name: Deploy
        uses: peaceiris/[email protected]
          deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
          publish_branch: master
          publish_dir: ./public
          cname: blog.arkey.fr

After this file has been pushed, it’s possible to inspect what this action has been doing, for how long, etc, at the repository actions page

Github Actions jobs

comments powered by Disqus