December 30, 2020 | 14:13

Hugo, Docker, GitLab CI, and more ...

Here I’m describing the process, how this blog is built and deployed using Hugo, Docker and GitLab CI. In essence, every time I push code (or in my case content for my Hugo blog) to a GitLab repository, the GitLab CI runner will create a Docker container and provide it in it’s own private Docker registry. Watchdog will notice the change and pull the image. That’s it.

Get Started

First, I registered at gitlab.com and created a private project that will be used to hold my Hugo content and the stuff we’re going to use to deploy the site. You will have to provide your credit card data before you’re allowed to use GitLab’s runners (There is a question in the FAQs that explains why). However, if you feel uncomfortable about that, you’re welcome to host your own GitLab instance or use your own runners (I didn’t try that).

Dockerfile

Now it’s time to create some files within the root of our new repo. First, we need a Dockerfile:

# This is a multi-stage Dockerfile (build and run)

# stage 1
# stage name is "hugobuilder" and will be referenced later
FROM alpine:latest AS hugobuilder

# The versions
ENV HUGO_VERSION 0.86.0
ENV GLIBC_VERSION 2.33-r0

# Install glibc (https://github.com/sgerrand/alpine-pkg-glibc)
# glibc is needed for Hugo extended, because it is dynamically linked
RUN set -x && \
  apk add --update wget ca-certificates libstdc++ && \
  wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \
  wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC_VERSION/glibc-$GLIBC_VERSION.apk && \
  apk --no-cache add glibc-$GLIBC_VERSION.apk && \
  wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC_VERSION/glibc-bin-$GLIBC_VERSION.apk && \
  wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC_VERSION/glibc-i18n-$GLIBC_VERSION.apk && \
  apk --no-cache add glibc-bin-$GLIBC_VERSION.apk glibc-i18n-$GLIBC_VERSION.apk && \
  /usr/glibc-compat/bin/localedef -i en_US -f UTF-8 en_US.UTF-8

# Install Hugo (extended version, glibc required!)
RUN wget -q -O /tmp/hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_Linux-64bit.tar.gz && \
  tar -zxvf /tmp/hugo.tar.gz -C /tmp/ && \
  mv /tmp/hugo /usr/bin/hugo && \
  rm -rf /tmp/hugo*
RUN hugo version

# Alternatively, install Hugo (glibc not required, you can comment out the glibc installation code block)
#RUN wget -q -O /tmp/hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.tar.gz && \
#  tar -zxvf /tmp/hugo.tar.gz -C /tmp/ && \
#  mv /tmp/hugo /usr/bin/hugo && \
#  rm -rf /tmp/hugo*
#RUN hugo version

# Clean-up
RUN apk del wget ca-certificates && \
  rm /var/cache/apk/*

# The source files are copied from "./hugo" subdir in our GitLab repo to "/site" within the container
COPY ./hugo/ /site
WORKDIR /site

# And then we just run Hugo
RUN /usr/bin/hugo --minify


# stage 2
#FROM nginx:1.15-alpine
FROM nginx:alpine


WORKDIR /usr/share/nginx/html/

# Clean the default public folder
RUN rm -fr * .??*

# Finally, the "public" folder generated by Hugo in the previous stage ("hugobuilder") is copied into the public fold of nginx
COPY --from=hugobuilder /site/public /usr/share/nginx/html

It’s a multi-stage Dockerfile, one stage to build the Hugo site and the second one to finally run ngninx to serve the site.

.gitlab-ci.yml

Next, create a .gitlab-ci.yml file in the repo’s root:

# use Docker to build the Docker image
image: docker:latest

services:
  - docker:dind

stages:
- build

before_script:
  # needed for git submodules (e.g. Hugo themes)
  - apk update && apk add git
  - git submodule update --init --recursive
  # login to private Docker registry
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

build:
  stage: build
  script:
    # build and push Docker image to registry
    - docker build --pull -t $DOCKER_IMAGE_TAG .
    - docker push $DOCKER_IMAGE_TAG

The file is used to configure our GitLab CI pipeline. We’ll make use of the following CI/CD variables (Settings -> CI/CD -> Variables):

  • CI_REGISTRY, which contains the Docker registry, in our case it is registry.gitlab.com
  • CI_REGISTRY_USER and CI_REGISTRY_PASSWORD, which contains the name and token value of a Personal Access Token with read_registry, write_registry and api (why you ask? see here) scopes (create in User Settings -> Profile -> Access Tokens)
  • DOCKER_IMAGE_TAG is basically the name of the image, in my case it is registry.gitlab.com/<username>/<project>

Hugo Content

Let’s move on to create our Hugo site. I’ll follow the official quick start guide, but the commands need to be amended to our needs:

# create a subfolder "./hugo" for our Hugo site
$ hugo new site hugo
# use a theme as submodule
$ git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git hugo/themes/ananke
# add the theme to the site configuration
$ echo theme = \"ananke\" >> hugo/config.toml
# create a first post which is not a draft (for testing purposes)
$ mkdir -p hugo/content/posts/
$ cat <<EOF > hugo/content/posts/my-first-post.md
---
title: "My First Post"
date: 2020-12-30T14:13:17+01:00
---

This is my very first post.
EOF

After pushing all to the repo, check the CI deployment on the GitLab project site within CI/CD -> Pipelines.

Run Docker Container

If everything worked well, you’ll find the Docker image within Packages & Registries -> Container Registry. Copy the link to the image by clicking on the copy button next to the name. On your server run the following commands:

# authenticate to the Gitlab Docker registry
$ docker login registry.gitlab.com
# run the docker container (may differ dependent on your needs)
$ docker run <put the link to the image from the registry here>:latest

This should download and run our Docker image.

Watchtower

We’re nearly at the end. The last piece of the puzzle is using Watchtower to automatically pull the Docker image from GitLab’s registry. Watchtower supports private Docker image registries. If you have used the docker login command above, Watchtower will fetch the credentials from the configuration file config.json:

version: '3'

services:
  hugo:
    container_name: hugo
    image: ${REGISTRY_IMAGE}
    restart: unless-stopped
    labels:
      - "traefik.http.routers.hugo.rule=Host(`${HUGOBLOG_HOST}`)"
      # Traefik stuff for TLS and Security Headers (won't be explained in this blog post!)
      - "traefik.http.routers.hugo.tls.certResolver=default"
      - "traefik.http.routers.hugo.tls=true"
      - "traefik.http.routers.hugo.middlewares=secHeaders@file"
      # include into Watchtower scope named "hugo"
      - "com.centurylinklabs.watchtower.scope=hugo"
    networks:
      - traefiknet
  watchtower:
    # only update container with name "hugo"
    command: hugo
    container_name: hugo-watchtower
    image: containrrr/watchtower
    restart: unless-stopped
    volumes:
     - /var/run/docker.sock:/var/run/docker.sock
     - /etc/localtime:/etc/localtime:ro
     # fetch the stored credentials for GitLab registry
     - ~/.docker/config.json:/config.json
    env_file:
     - ./.env

# the Traefik network (won't be explained in this blog post!)
networks:
  traefiknet:
    external: true

Create an .env file in the same folder as the docker-compose.yml:

# Blog
REGISTRY_IMAGE=<put the link to the image from the registry here>:latest
HUGOBLOG_HOST=<the URL of your blog>

# Watchtower
WATCHTOWER_SCOPE=hugo			    # Define scope
WATCHTOWER_CLEANUP=true			    # Remove old images after updating
WATCHTOWER_DEBUG=false			    # Debug mode
WATCHTOWER_INCLUDE_RESTARTING=true	# Restart after updating
WATCHTOWER_POLL_INTERVAL=100		# Poll interval in sec

# Email Settings (recommended, will inform you about updated docker containers!)
#WATCHTOWER_NOTIFICATIONS=email
#WATCHTOWER_NOTIFICATION_EMAIL_FROM=
#WATCHTOWER_NOTIFICATION_EMAIL_TO=
#WATCHTOWER_NOTIFICATION_EMAIL_SERVER=
#WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=
#WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=
#WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=
#WATCHTOWER_NOTIFICATION_EMAIL_DELAY=1

As you can see, a dedicated Watchtower instance is used to check for updates available for the Hugo container every 100 seconds. The reason for using a separate Watchtower instance is, that there is a limit on how often the official Docker registry can be pulled.

© Pavel Pi 2021

Powered by Hugo & Kiss'Em.