Introduction

As you may have noticed this site is built using Hugo and Papermod theme. After playing around with Hugo and getting comfortable with it, I wanted to push the initial version of this site to the git repository and set up an automatic deployment pipeline to be able to easily publish updates to the web.

Since I am the lucky owner of a self-hosted GitLab instance which I’ve been using for more than a decade for my projects, it was an obvious decision to push this site there too. Same story with Cloudflare - I use their DNS services, and since they also provide static content hosting services - Pages, decided to give it a try.

Fast googling revealed that Cloudflare supports automatic deployments of Hugo-based sites from GitHub and GitLab…, but only for cloud-based SaaS version.

Luckily Cloudflare has Wrangler - command-line interface to manage Worker projects. You may ask what workers have to do with static pages? Well, they are managed by the same tool, see pages command documentation of Wrangler tool.

Below I will explain how to tie Wrangler together with GitLab CI/CD to perform deployment to Cloudflare Pages. Further steps assume you have knowledge and experience of using Cloudflare services, git, Hugo and GitLab CI/CD. I will not go into details for some of the steps.

Setting and Testing Locally

Below are the steps to prepare project, repository and test everything locally:

  • Create Hugo project
  • Change into project directory
  • Login with Wrangler CLI into your Cloudflare account: npx wrangler login, follow instructions
  • Create new Pages project, I will call mine pages-for-article, make sure your project git default branch matches with what you pass in --production-branch argument. Cloudflare will use it later to determine if you are publishing to production or preview environments: npx wrangler pages project create pages-for-article --production-branch main
  • Initialize git repository, do this before deploying to Pages, as Wrangler uses git metadata for deployments
  • Build Hugo project: hugo
  • Now, run deployment: npx wrangler pages deploy public --project-name pages-for-article
  • This is what you should see from Cloudflare dashboard Workers & Pages section, notice main branch and Production environment in the screenshot: Pages project in Cloudflare
  • Add public/ to .gitignore
  • Commit and push your project to git

Now we are all set to move to the GitLab CI/CD configuration.

Setting GitLab CI/CD

Requirements

Before going into configuration details, I like to define my requirements for CI/CD pipeline:

  1. I want to have two environments - production and preview (staging)
  2. I want to deploy changes from default branch, tags or merge requests only
  3. I want to be able to see draft content in the preview environment
  4. I want to be able to deploy any changes except tags to preview environment
  5. I want to be able to deploy to production environment changes only from default branch or tags

GitLab Project Configuration

Wrangler requires certain authentication environment variables to be present to operate from CI/CD. See Run Wrangler in CI/CD guide. Add these variables in the GitLab project under Settings -> CI/CD -> Variables. Make variables masked, unprotected and non-expandable. GitLab CI/CD variables for Cloudflare

Make sure that the project has at least one runner with a docker execution environment under Settings -> CI/CD -> Runners.

Pipeline Definition

GitLab uses .gitlab-ci.yml to describe jobs that make up the CI/CD pipeline. I will not go into detail of every aspect of that file, but rather highlight parts that are made up to fulfill above requirements. Complete .gitlab-ci.yml file is available at GitHub.

workflow:rules describes the requirement to create and run a pipeline only for the default branch, merge requests commits and tags, omitting commits to branches without merge requests.

Because of the requirement to have draft content available in the preview environment, two distinct build jobs are needed - build:staging and build:production, first one will build a site with drafts, second - without, see script section. Both jobs inherit from common .build template, which describes common build configuration - using alpine image for runtime environment and installing hugo using apk package manager, collecting build artifacts from public folder.

The most interesting part of .gitlab-ci.yml is the .deploy template. What it does - it runs Wrangler deploy command with additional arguments - project name, branch, commit hash, and commit message. Values for the latter three arguments are taken from corresponding variables defined in the same template, which in turn reference pre-defined GitLab CI/CD variables by default.

variables:
  CLOUDFLARE_BRANCH: "$CI_COMMIT_BRANCH"
  CLOUDFLARE_COMMIT_HASH: "$CI_COMMIT_SHA"
  CLOUDFLARE_COMMIT_MESSAGE: "$CI_COMMIT_MESSAGE"

You may ask - why is it so complex? As mentioned earlier, Wrangler can extract this information from git itself. Yes, indeed, it can, but Wrangler also makes a decision on where to deploy - production or preview environment based on the branch name. Default branch always goes to production, period, you can’t affect it, this basically breaks requirements 4, 5.

Luckily, Wrangler has these arguments and you can pass whatever you want making it possible to workaround behavior of always publishing default branch changes to production. And this is what is being done in deploy:staging and deploy:production which inherit from the .deploy template.

Here is a closer look at deploy:staging variables and rules section:

  variables:
    CLOUDFLARE_BRANCH: "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' 
      when: manual
      variables:
        CLOUDFLARE_BRANCH: "$CI_COMMIT_BRANCH-preview"
    - if: '$CI_COMMIT_TAG' 
      when: never
    - when: manual
  • First rule makes it possible to deploy default branch changes to preview environment by adding -preview postfix to the default branch name, this way Wrangler will treat it as a non-default branch.
  • Second rule disallows deployment of tags to preview, since I will use tags as release milestones for this project.
  • For all other cases, i.e. merge request commits - merge request source branch name will be used, as defined in variables section.

Please note that preview deployments are manual actions and require launching these jobs from pipeline view in the GitLab by hand. Also for pipelines not to appear as “blocked” in GitLab, deploy:staging has allow_failure: true set.

Cool thing about Cloudflare Pages is that preview deployments can be accessed via <branch-name>.pages-for-article.pages.dev address. For default branch preview for this project it is https://main-preview.pages-for-article.pages.dev.

These are deploy:production rules:

  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      when: manual
    - if: '$CI_COMMIT_TAG' 
      when: always
      variables:
        CLOUDFLARE_BRANCH: "$CI_DEFAULT_BRANCH"
        CLOUDFLARE_COMMIT_MESSAGE: "$CI_COMMIT_TAG - $CI_COMMIT_TAG_MESSAGE"
    - when: never
  • First rule allows manually deploying anything from the default branch to the production environment; this normally is not used, but sometimes may come in handy.
  • Second rule - automatically deploys to production environment when tag is created. Since Wrangler has no support for tags, passing actual tag name into branch name would result in tag being deployed into preview environment instead of production, to workaround this - default branch name is fed into branch name, and actual tag name along with tag message is set in commit message.

That’s it, these rules satisfy my requirements set above, and I can use GitLab to track and publish my blog changes to Cloudflare pages.

Further Improvements

  • I am using bare alpine and node docker images which download hugo and wrangler for each job invocation. This negatively affects pipeline speed and generates excess traffic, a better approach would be to use pre-built images containing these tools. On the other hand, impact is negligible, as whole pipeline takes around 30 seconds to build and deploy.
  • Add a separate deployment job to the preview environment without draft content to make it identical to what is going to be deployed to the production environment.

Conclusion

GitLab and Cloudflare are great services with lots of useful features and customization options. With some exploration and trial and error approach I was able to create CI/CD pipeline configuration that meets my requirements for publishing Hugo blog to Cloudflare Pages from a self-hosted GitLab instance.

Complete .gitlab-ci.yml file is available on GitHub.