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.105.0
# Install packages
RUN set -x && \
apk add --update wget ca-certificates libstdc++
# Install Hugo
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: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.
.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 Build -> Pipelines.
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. 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 registry.gitlab.com
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
https://docs.docker.com/engine/reference/commandline/login/#credentials-store
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'
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"
container_name: hugo-watchtower
image: containrrr/watchtower
restart: unless-stopped
volumes:
# 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_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=500 # 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 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.