Blog engineering

Presentation of my publishing method.

For my blog I chose the Hugo1 static website generator. Unlike the dynamic site like Wordpress the static site generators are very easy to use: there is no administration interface, no database to manage and no need to use a specialized editor because I write my articles in Markdown.

The principle is the following, I work locally on my computer and I broadcast the articles on my self-hosted server. To follow the evolution of my blog, I use the Git version manager and to keep a remote copy I use the Gitlab service on

After writing my article, how my blog is updated automatically on my self-hosted server?

My first solution was to use Ansible for deploying articles on my blog. The configuration files allowed me to do a good job but I am not fully satisfied with that. Indeed, to update the blog you must install Ansible, which is not always possible, especially on my smartphone.

My current solution is based on Gitlab’s CI service : we create a file called gitlab-ci.yml, located at the root of the Git repository, which defines a set of actions (or deployment pipeline) to be performed in chronological order.

The deployment pipeline gitlab-ci.yml :

  - build
  - deploy
  stage: build
  image: alpine:latest
    - private
    - master
      - $CI_COMMIT_MESSAGE =~ /deploy/
  - apk update && apk add hugo git
  - git submodule update --init --recursive
  - hugo -d public
    - public
    expire_in: 10 mins
  stage: deploy
  image: alpine:latest
    - private
    - master
      - $CI_COMMIT_MESSAGE =~ /deploy/
    - apk update && apk add openssh-client bash rsync
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - rsync -rz --delete public/ $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH

The deployment pipeline
It consists of two stages:

  1. build : allows to build the public folder which contains the structure and the translated blog files from Markdown to html/css. The public folder is then cached to be used by the next step.
  2. deploy : allows to copy with Rsync this public folder on my self-hosted server.

This pipeline is containerized in Gitlab.

The choice of the docker image
If possible I prefer the Alpine image, it’s light which allows me to reduce the execution time of the pipeline. I compared with a Debian image, I went from 5min34s to 2min11s with the Alpine image.

  image: alpine:latest

The conditions to be respected
The pipeline is executed if and only if the branch is master and the commit message contains the keyword deploy.

    - master
      - $CI_COMMIT_MESSAGE =~ /deploy/

Blog theme management
The theme I use for the blog is called Nederburg2, the source code is available on Gitub.
With the Hugo static site generator there is a directory named theme. It is in this directory that I import the theme Nederburg with the command git submodule :

git submodule add

The theme is a submodule, it’s just the URL address that is defined. That’s why I use the following command in the script gitlab-ci.yml to copy the theme code.

    git submodule update --init --recursive

Generation of SSH keys
In order for the Gitlab CI service to securely deploy the public folder to my self-hosted server, creation of an SSH key pair is required.
I create an SSH key pair ed25519 which is an implementation of the twisted Edwards Curve. It offers the same level of security as RSA while consuming less CPU resources.
Keys are generated without passphrase.

ssh-keygen -f ~/.ssh/gitlabci -t ed25519  
cat ~/.ssh/ >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

For security, the private key is not written in the deployment file gitlab-ci.yml. I’m using an environment variable, which means the private key is written dynamically, when the deployment pipeline is running.

I define my environment variables in the options of my Framagit account.

See image below :

  • SSH_KNOWN_HOSTS : The host key. Used to verify that the client is connected to the correct host. I get it with the following command : ssh-keyscan mydomainname.
  • SSH_PRIVATE_KEY: The private key without passphrase.

Setting up a GitLab Runner instance

    - private

Framagit provides a GitLab Runnner to run a deployment pipeline. For security reasons, I run GitLab Runner from my self-hosted server.

The command below generates the configuration file : /srv/gitlab-runner/config/config.toml with the URL address of the framagit server, authentication token and other options.

docker run --rm -v /home/gitlab-runner/config:/etc/gitlab-runner gitlab/gitlab-runner register \
  --non-interactive \
  --executor "docker" \
  --docker-image alpine:latest \
  --url "https://*******" \
  --registration-token "******" \
  --description "private-runner" \
  --tag-list "private" \
  --run-untagged="false" \
  --locked="true" \

Then I start the GitLab Runner instance :

docker run -d --name gitlab-runner --restart always -v /home/gitlab-runner/config:/etc/gitlab-runner -v /var/run/docker.sock:/var/run/docker.sock  gitlab/gitlab-runner:latest


Web server setup
To serve the static pages of the blog I use the Nginx web server. The service listens on port 80, but I have several other web services with port 80 open, that’s why I use the Traefix reverse-proxy. In addition Traefix automatically generates TLS certificates.

Below is the file docker-compose.yml

version: '3.7'
    container_name: traefik
    restart: always
    image: traefik:v1.7.16
      - 80:80
      - 443:443
      - /var/run/docker.sock:/var/run/docker.sock
      - /home/traefik/traefik.toml:/traefik.toml
      - /home/traefik/acme.json:/acme.json
      - /home/traefik/log:/var/log/traefik
      - ""
      - "traefik.enable=true"
      - traefiknet

    container_name: blog_web
    image: nginx:stable-alpine
    restart: always
      - /home/blog/output:/usr/share/nginx/html:ro
      - ""
      - ""
      - "traefik.enable=true"
      - "traefik.port=80"
      - internal
      - traefiknet
    external: true
    external: false

Practical case of publication
The procedure is simple (that is indeed the goal!).

  1. I write the article in Markdown.
  2. The writing of the article finished, I do a git commit and I add in the commit message the keyword deploy.
  3. I do a git push. The blog publication pipeline is automatically triggered.
  4. I wait two minutes, the time that the pipeline ends

And here it is, the new article is visible on the blog*

* You will probably need to bypass your cache.


  1. ↩︎

  2. ↩︎

Antoine CAVARD Auteur :