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.comCI_REGISTRY_USER
andCI_REGISTRY_PASSWORD
, which contains the name and token value of a Personal Access Token withread_registry
,write_registry
andapi
(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 isregistry.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.