Dockerized Hugo with GitLab CI/CD

How we use Hugo, Docker, and GitLab CI to build this tech blog

May 24, 2019

This article is the third of a series which examines the technical reasons behind the renewed interest for static websites, from both a content writer and a developer perspective.

Previous posts:


Hugo describes itself as “The world’s fastest framework for building websites”. Hugo is “one of the most popular open-source static site generators”.

This tech blog is being built with Hugo, serving static pages with an nginx docker container (built and deployed with GitLab and Ansible).

GitLab CI

First, let’s see how GitLab CI builds and deploys our tech blog.

Here is the .gitlab-ci.yml file, documented.

  - build
  - deploy

# this hidden job is specific to our Ansible
# it deploys a named ("techblog") docker container
# see
.deploy: &deploy
  - >
    cd /var/ansible &&
    sudo -E ansible-playbook deploy_docker_callr_applications.yml
    -i "inventories/$ANSIBLE_INVENTORY"
    -e "app=techblog tag=latest"    

  stage: build
  # remember to use specific version in your build images
  # see
  image: docker:18-git 
  # we only build the "master" branch
    - master
  # fetching submodules here because usually themes are git submodules
  # see also the commented variables below
    - git submodule sync --recursive
    - git submodule update --init --recursive
    - echo -n $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin $CI_REGISTRY
    # the image is tagged as latest, because it is the master branch
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:latest
  # variables:
  # -> this did not work for us, we had to use before_script above.
  # -> SSL certificate problem: unable to get local issuer certificate
  # ->
  # -> maybe an http proxy issue specific to our platform.

  stage: deploy
  script: *deploy
  allow_failure: false
  # deploying master only
    - master
    name: production
  # we tag specific runners here in our platform
    - deployment
    # because the deployment stage does not need the source code anymore
    # we set GIT_STRATEGY to none
    GIT_STRATEGY: none
    # those variables are being used in the *deploy hidden job (above)

In our .gitlab-ci.yml there are two stages: build and deploy. Both are run only on the master branch.

After fetching the git submodules, the build stage builds a docker image, tagged latest, using a simple docker build.

Then, the deploy stage runs Ansible on specific deployment runners (see CI/CI: Using GitLab + Docker + Ansible for a more in-depth explanation of this).


Here is our Dockerfile, commented.

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

# Remember to target specific version in your base image,
# because you want reproducibility (in a few years you will thank me)
FROM alpine:3.9 AS build

# The Hugo version

ADD${VERSION}/hugo_${VERSION}_Linux-64bit.tar.gz /hugo.tar.gz
RUN tar -zxvf hugo.tar.gz
RUN /hugo version

# We add git to the build stage, because Hugo needs it with --enableGitInfo
RUN apk add --no-cache git

# The source files are copied to /site
COPY . /site

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

# stage 2
FROM nginx:1.15-alpine

WORKDIR /usr/share/nginx/html/

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

# This inserts a line in the default config file, including our file ""
RUN sed -i '9i\        include /etc/nginx/conf.d/;\n' /etc/nginx/conf.d/default.conf

# The file "" is copied into the image
COPY _docker/ /etc/nginx/conf.d/
RUN chmod 0644 /etc/nginx/conf.d/

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

The Dockerfile is multi-stage, keeping the image size down.

  • The first stage builds the static website with Hugo.
  • The second stage configures nginx to serve the static pages.

This line:

RUN sed -i '9i\        include /etc/nginx/conf.d/;\n' /etc/nginx/conf.d/default.conf

will update /etc/nginx/conf.d/default.conf from this:

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;

into this:

    location / {
        include /etc/nginx/conf.d/;

        root   /usr/share/nginx/html;
        index  index.html index.htm;

This is the file, inspired stolen from

# cache.appcache, your document html and data
location ~* \.(?:manifest|appcache|html?|xml|json)$ {
  expires -1;

# Feed
location ~* \.(?:rss|atom)$ {
  expires 1h;
  add_header Cache-Control "public";

# Media: images, icons, video, audio, HTC
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
  expires 1M;
  access_log off;
  add_header Cache-Control "public";

# CSS and Javascript
location ~* \.(?:css|js)$ {
  expires 1y;
  access_log off;
  add_header Cache-Control "public";

As stated on

The above configuration disables caching for manifest, appcache, html, xml and json files. It caches RSS and ATOM feeds for 1 hour, Javascript and CSS files for 1 year, and other static files (images and media) for 1 month.

The caches are all set to “public”, so that any system can cache them. Setting them to private would limit them to being cached by private caches, such as our browser.

Because Hugo generates new file paths for CSS and JS files with every build, served files can be safely cached for a year.

Using Lighthouse in the Chrome DevTools, we obtain:

Performance: 100/100, Best Practices: 93/100, SEO: 100/100

Tech Blog Performance

Which are not bad results.

The -7 in Best Practices is because we still use HTTP/1.1 instead of HTTP/2.0.

Further Reading

Thanks for reading!

I am currently hiring a devops engineer to help me build the future of telcos at Callr.

Do you speak French and English, love what you do and know a thing or two about Unix, Docker, Ansible and software engineering?

Reach out to me and let's have a chat.