Skip to main content
  1. Articles/

Migrate from Ghost to Hugo

·1287 words·7 mins·
CI/CD Docker Git Hugo
d3vyce
Author
d3vyce
Cybersecurity, Devops, Infrastructure
Table of Contents
Migration - This article is part of a series.
Part 1: This Article

Current solution
#

I’ve had a blog since early 2022. Historically, I chose Ghost to make this site. At the time, several factors led me to choose this CMS rather than another:

  • Ease of deployment and use
  • Online administration page and article editor
  • Simple, modern theme
  • Regular updates

Ghost based blog

But after using it for quite some time, a number of problems have arisen that can’t really be corrected:

  • Limited customization
  • Complex theme modification if you want to be able to update it afterwards
  • Significant resource requirements for Ghost+Mysql (see Conclusion)
  • No options for advanced content organization
  • Too many unused options to justify this choice (subscription, user account, etc.)

All these problems, combined with the fact that my needs had evolved, led me to change the technical solution for my blog.

Choosing a new solution
#

Today, there are many options for blogging. Third-party hosted options like Medium, CMS like Wordpress and Ghost, but also static websites generators. For this new version of my blog, I’ve opted for a static websites generator.

Here again, there are several solutions, but I’ve settled on Hugo.

Hugo is a GO-based opensource framewok created in 2013. It’s known for being very fast, highly customizable and with a very active community.

After choosing Hugo I had to choose a theme, I had several requirements in terms of features. I ended up choosing Blowfish.

It’s a highly flexible and customizable theme, regularly updated, with a minimalist, modern design.

Migration
#

Settings
#

To set up hugo and the theme, I used the Blowfish documentation. Many elements are detailed and give a good start.

I’ve chosen to structure my blog in 2 parts: articles and projects. This will enable me to quickly and easily create pages dedicated to my personal projects, while remaining separate from the rest of the blog’s publications.

Contents and optimization
#

For the moment I’ve only migrated the posts from my old blog and not the CTF writeups. I haven’t found a good solution for transforming Ghost posts into a format that Hugo can use autonomously. I’ll try to make a script that will do it in an optimized way and with support for image migration.

Once the articles had been manually migrated, I looked into support for web-optimized image formats such as webp and avif. It turns out that hugo supports it, but doesn’t take into account the compatibility option for older browsers. After a bit of research, I found the following article that answered my problem:

WebP and AVIF images on a Hugo website

To create this solution, I first need to generate a webp version of my png images. To do this, I use the following command:

find ./content/ -type f -name '*.png' -exec sh -c 'cwebp -q 90 $1 -o "${1%.png}.webp"' _ {} \;

I can then create the file layouts/_default/_markup/render_images.html with the code shown in the article. And then, all that’s left is to integrate the images into my articles using the following tag:

![TEXT](img/image-X.webp)

Storage and automatic deployment
#

Git-LFS
#

My blog is now file-based, so I’m going to store it in a git project (more precisely on my Gitea intance). There’s a problem: it’s made up of a lot of images, which aren’t really the type of file to put in a git project. To solve this problem, I’m going to use git LFS (Large File Storage) feature.

To do so, I first install git-lfs and add it to the project, then set the file extensions I want to put in LFS:

apt install git-lfs
git lfs install
git lfs migrate \
        import \
        --include="*.jpg,*.svg,*.ttf,*.woff*,*.min.*,*.webp,*.ico,*.png,*.jpeg" \
        --include-ref=refs/heads/main

And that’s all there is to it! You just need to make sure that LFS is also enabled on Gitea, which it is. The next time you push, files with include extensions should be in LFS.

CI/CD
#

Last step of the migration, I’m going to make an automatic deployment of my blog when I push a new article. To do this, I’ll use CI/CD.

At first I used a CI that generated an image with the version of hugo I wanted, then generated my blog and finally added it to a nginx image. But after a few tests I realized that creating an image with hugo took up to 5min on my worker. So I’m going to create 2 workflows; one for creating the Hugo image and the second for building my blog. In fact, I only need a new hugo image when I bump its version.

Hugo docker image
#

Start with the Hugo image with its Dockerfile and associated workflow:

FROM golang:1.22-alpine AS build

ARG CGO=1
ENV CGO_ENABLED=${CGO}
ENV GOOS=linux
ENV GO111MODULE=on

RUN apk update && \
    apk add --no-cache gcc musl-dev g++ git
RUN go install -tags extended github.com/gohugoio/[email protected]
name: Build Hugo Docker Image

on:
  push:
    paths:
      - "hugo.Dockerfile"

jobs:
  build docker:
    runs-on: linux_amd
    steps:
      - name: checkout code
        uses: actions/checkout@v3
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      - name: Login to Docker registry
        uses: docker/login-action@v2
        with:
          registry: git.d3vyce.fr
          username: ${{ github.actor }}
          password: ${{ secrets.GIT_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          file: ./hugo.Dockerfile
          platforms: linux/amd64
          push: true
          tags: git.d3vyce.fr/${{ github.repository }}:latest

This workflow will only be triggered if I make a modification to the hugo.Dockerfile (for example, if I bump hugo’s version).

Blog docker image
#

I can then use this image in every build of my blog with the following Dockerfile:

# Build Stage
FROM git.d3vyce.fr/d3vyce/hugo:latest AS build

WORKDIR /opt/blog
COPY . /opt/blog/

RUN git submodule update --init --recursive && \
    git -C themes/blowfish/ checkout v2.58.0
RUN hugo

# Publish Stage
FROM nginx:1.25-alpine

WORKDIR /usr/share/nginx/html
COPY --from=build /opt/blog/public /usr/share/nginx/html/
COPY nginx/ /etc/nginx/

EXPOSE 80/tcp

As you can see, to avoid basing myself on the submodule’s upstream, I’m going one checkout further to base myself on a tag.

name: Build Blog Docker Image

on:
  push:
    branches:
      - main

jobs:
  build docker:
    runs-on: linux_amd
    steps:
      - name: checkout code
        uses: actions/checkout@v3
        # with:
        #   lfs: 'true'
      - name: Checkout LFS
        run: |
          function EscapeForwardSlash() { echo "$1" | sed 's/\//\\\//g'; }
          readonly ReplaceStr="EscapeForwardSlash ${{ gitea.repository }}.git/info/lfs/objects/batch"; sed -i "s/\(\[http\)\( \".*\)\"\]/\1\2`$ReplaceStr`\"]/" .git/config
          git config --local lfs.transfer.maxretries 1
          /usr/bin/git lfs fetch    origin refs/remotes/origin/${{ gitea.ref_name }}
          /usr/bin/git lfs checkout                    
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      - name: Login to Docker registry
        uses: docker/login-action@v2
        with:
          registry: git.d3vyce.fr
          username: ${{ github.actor }}
          password: ${{ secrets.GIT_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          file: ./Dockerfile
          platforms: linux/amd64
          push: true
          tags: git.d3vyce.fr/${{ github.repository }}:latest

This workflow will be executed for each commit to master. As you can see, I didn’t use the LFS option of actions/checkout@v3. This is due to a bug with Gitea: https://gitea.com/gitea/act_runner/issues/164

To fix this bug I had to add a Checkout LFS step as recommended in the issue.

Conclusion: before/after comparison
#

After completing the migration, I compared the 2 solutions.

Lighthouse result for Ghost based blog: Ghost based blog lighthouse result

Lighthouse result for Hugo based blog: Hugo based blog lighthouse result

Metric Ghost (Dawn) Hugo (Blowfish)
First Contentful Paint 0.5 s 0.3 s
Largest Contentful Paint 0.6 s 0.4 s
Total Blocking Time 0 ms 0 ms
Cumulative Layout Shift 0.001 0
Speed Index 0.6 s 0.3 s

We can see that Hugo is faster in all the aspects tested by lighthouse than Ghost. In terms of resources, Hugo consumes much less, using around 10/15MB of RAM compared with Ghost, which consumes over 230/250MB of RAM (not counting the mysql that Ghost uses to run).

In conclusion, I’m not disappointed that I took the time to migrate my blog to Hugo. It took time, but it will pay off in time for the ease of updating, customization, …

Migration - This article is part of a series.
Part 1: This Article

Related

Teleinfo Exporter
Docker Esp32 Grafana Mqtt Prometheus Python
How to make daily backups of your HomeLab with Rclone?
·933 words·5 mins
Backup Docker Storage Tools
Authelia : a selfhosted SSO
·1341 words·7 mins
Authentication Docker SSO Tools