Spinning up a Hugo-powered blog with GitLab Pages

In the name of documenting some of my free time for fun and profit™, I am starting a blog. I am many things, but I am not a web developer, so I need something quick and easy to use.

At the same time, as a programmer, I really appreciate the ability to script my text documents where needed, and to have automation around my chosen tools. I have elected to use the popular Hugo as the right combination of a) powerful and b) easy to get started with.

So, as my first challenge for myself, I would like to automatically build and publish the blog from source every time I upload a change. I would also like to secure the site with HTTPS, because it’s 2019.

Unfortunately, my choice of Hugo means I am out of the GitHub Pages ecosystem, which only allows you to use Jekyll. This isn’t really the end of the world, as I am a huge fan of GitLab’s continuous integration (CI), including their very generous free tier of build servers. So I’ll give a quick run-through of what you need to do to get a site online, for free1, in about an hour.

This assumes you:

  1. 10 min Have followed the excellent Hugo Getting Started Guide
  2. 5 min Have initialized a Git repository on GitLab and pushed your blog/code there
  3. 20 min Have bought your domain of choice, or are okay with using something.gitlab.io.

GitLab CI

Right. Let’s set up CI. If you’re already generating your site on the command line with Hugo, this is cake. We’ll use a Docker image with Hugo installed, generate the site, and tag the generated folder as the artifact. Then, GitLab will serve this folder. They have some really good documentation about all this.

For some reason, there isn’t an official Hugo Docker image, so for now I’ll use one supplied by GitLab that appears to be well maintained.

Here’s the .gitlab-ci.yml file.

image: registry.gitlab.com/pages/hugo:latest


  - hugo
    - public
  - master

The important parts are that the job is named pages, the content appears in a public/ directory in the artifact, and the job only runs on the master branch—you don’t want to publish whichever branch you last pushed to!

Commit this to the root of the repository, and push. GitLab will run the script and publish your site! In my experience, this takes up to 30 minutes to propagate. Be patient, and do all your validation on your workstation before you publish.

Custom domain

If you want a custom domain, head to your GitLab repo’s Settings > Pages and add the domain. You will need to validate it by adding a custom DNS TXT record, and point the actual domain at GitLab servers.

Note: be sure to leave the TXT record in place! GitLab periodically revalidates it and your site will get un-validated if you remove it.


If you want a custom domain name for your blog, and you want it accessible via HTTPS, this is where it gets hairy. GitLab does not quite yet support automatic HTTPS with LetsEncrypt for custom domains like GitHub does. So, there are a couple ways to do HTTPS. We could go through CloudFlare, but that requires monkeying with your site’s DNS nameservers and adds an extra account to keep track of. We could also use LetsEncrypt and maybe even use a GitLab CI job to pass the challenges and keep the certificate updated automatically.

The DNS challenge, which simply involves adding a DNS record much like GitLab’s challenge earlier, would be ideal for this, because it could be accomplished entirely from a runner with plain certbot. Unfortunately, most DNS providers provide a terrible or nonexistent API, so you’d need to use something like Google Cloud DNS for this to be a viable option.

The HTTP challenge sounds like a lot of pain. Normally you’d have a webserver watching a folder that certbot can poke directly, and the challenge would be quick and automated. But with a Git-tracked and Hugo-processed site, you’d need to commit the file, push the file, wait for GitLab Pages to update (which can take a while), etc. This does not sound fun.

Fortunately, Rodrigo Dato has done the hard work and written a tool called gitlab-le that does all the steps you need. I have built and published a gitlab-le Docker image that you can grab:

$ docker run --rm -it registry.gitlab.com/thirtythreeforty/gitlab-letsencrypt gitlab-le

Let’s call this tool from CI by adding a job in .gitlab-ci.yml:

  image: registry.gitlab.com/thirtythreeforty/gitlab-letsencrypt
  - |
    gitlab-le \
      --domain YOUR_DOMAIN \
      --email YOUR_EMAIL \
      --repository YOUR_BLOG_REPO_URL \
      --token $GITLAB_ACCESS_TOKEN \
      --path static/.well-known/acme-challenge
  when: manual

When you generate your access token, it would be smart to put it in a CI variable, so it’s not checked into your repository. Note also that Hugo copies things in the static folder as-is to the output folder, so that’s where we’ll have gitlab-le put the challenge info.

When you run this, either via Docker or as a GitLab job, you should see something like:

By using Let's Encrypt, you are agreeing to the TOS at https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
Uploaded challenge file, polling until it is available at http://www.thirtythreeforty.net/.well-known/acme-challenge/7Vui_I58QATNbtTz1C3CasYriSShSbJKxnCk0IKY5FE
Could not find challenge file. Retrying in 30s...
Could not find challenge file. Retrying in 30s...
Success! Your GitLab page has been configured to use an HTTPS certificate obtained from Let's Encrypt.
Try it out: https://www.thirtythreeforty.net (GitLab might take a few minutes to start using your certificate for the first time)

This certificate expires on Mon Sep 02 2019 01:59:40 GMT+0000 (Coordinated Universal Time). You will need to run gitlab-le again at some time before this date.

You can add this as a periodic job in GitLab if you like.

Wrapping up

So this blog is built with Hugo, compiled on GitLab CI, hosted on GitLab Pages, and secured via LetsEncrypt. Let me know if you have any feedback on any of this!

  1. Renting a domain will of course cost a small fee. [return]