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 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).


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

# Install packages
RUN set -x && \
  apk add --update wget ca-certificates libstdc++

# Install Hugo
RUN wget -q -O /tmp/hugo.tar.gz${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

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

# stage 2
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 nginx to serve the site.


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

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

  - docker:dind

- build

  # needed for git submodules (e.g. Hugo themes)
  - apk update && apk add git
  - git submodule update --init --recursive
  # login to private Docker registry

  stage: build
    # 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
  • 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<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 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/
title: "My First Post"
date: 2020-12-30T14:13:17+01:00

This is my very first post.

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


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. First, we need to create another Personal Access Token for Watchtower to fetch our freshly built container from GitLab’s container registry:

  • The token name can be something like docker_user
  • Give it the read_registry scope
$ docker login
Username: # the token's name, e.g. "docker_user"
Password: # the token's value
WARNING! Your password will be stored unencrypted in /srv/docker/.docker/config.json.
Configure a credential helper to remove this warning. See

Login Succeeded

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'

    container_name: hugo
    image: ${REGISTRY_IMAGE}
    restart: unless-stopped
      - "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"
      - traefiknet
    # only update container with name "hugo"
    container_name: hugo-watchtower
    image: containrrr/watchtower
    restart: unless-stopped
    # I'm running rootless docker, so you may need to use "/var/run/docker.sock"
     - /run/user/1001/docker.sock:/var/run/docker.sock
     - /etc/localtime:/etc/localtime:ro
     # fetch the stored credentials for GitLab registry
     - ~/.docker/config.json:/config.json
     - ./.env

# the Traefik network (won't be explained in this blog post!)
    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=500		# Poll interval in sec

# Email Settings (recommended, will inform you about updated docker containers!)

As you can see, a dedicated Watchtower instance is used to check for updates available for the Hugo container every 500 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.