As I wrote in introduction post - I tend to overthink. I decided to start with absolute minimum, as if against myself. So, how did I manage to design this site?

Designated goals

By saying “absolute minimum” I think of:

  • using static site generator in order to be able to work with Git, not with CMS based on database and web interface
  • #continuous deployment, so I can publish in automated way exactly when I want it
  • minimal, though tidy and functional user interface
  • multi language support (#i18n), both for content and URLs
  • site available under HTTPS protocol
  • no costs required (one-time or recurrent)

Decisions made

Having my tendency to overthinking in mind, I didn’t want to spend much time on research for potential solutions. I chose what I’ve already known plus brand-new things that emerged during implementation process, but I’ll cover it later.

Gitlab

For the code I chose Gitlab because:

  • I use it on daily basis for years, and I know its capabilities much more than Github’s
  • it allows nested project groups, which I personally really like in terms of project organisation

Cloudflare

At the beginning I wanted to use Cloudflare only for DNS management and for handling SSL certificates, but after configuring domains I saw Pages section, and I decided to try it out. I did not know this feature, even though general availability was announced year ago.

Hugo - Static Site Generator

Hugo is a static site generator, which is highly flexible, extendable and performant. I did not use it before, but I saw many technical websites built with it, so I assumed it’s right fit for content management.

Action plan

Having decided what building block I’ll use I did research how to glue them together. The plan was to create Cloudflare Pages, point custom domains there and prepare automatic deployment for content built with Hugo, using Gitlab CI.

graph LR; A[Content creation] -->|git push| B[Gitlab Pipeline] B --> C[Cloudflare Pages PL] B --> D[Cloudflare Pages EN]

It looks simple, but as it turned out later, there were complications 😅

Site skeleton

Of course at the very beginning Gitlab project and basic Hugo installation had to be created. There are many installation methods, I’ve used brew install hugo. After creating new project using hugo new site command, I could start working on it.

Choosing theme

I’ve decided to use Minima, because I wanted to focus on content, not on visual effects. Feature list was good enough for my needs.

Installation of themes is done with Git submodules:

git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/paper-mod

The only files that we commit to repository are .gitmodules and directory entry, that in fact is a pointer to the submodule’s commit, which was installed (and can be later restored using git submodule init).

In order to use theme we must configure Hugo by adding theme: paper-mod in config/_default/config.yml

Support for i18n

I own 2 domains, so I wanted to make use of them and serve language versions separately, without using infix in URL’s path. Like in documentation I’ve created separate content/en and content/pl directories with proper configuration:

# config/_default/languages.yml

pl:
  contentDir: content/pl
  baseURL: https://blog.codito.pl/
  languageName: ':poland:'
  languageCode: pl
  paginatePath: strona
  title: Codito.pl
  weight: 1
  taxonomies:
    category: kategorie
    tag: tagi
    series: serie
  params:
    # Must be set under languages.xx.params since Hugo 0.112 (instead of languages.xx)
    # see: https://gohugo.io/content-management/multilingual/#changes-in-hugo-01120
    languageAltTitle: Polski

en:
  contentDir: content/en
  baseURL: https://blog.codito.dev/
  languageName: ':gb:'
  languageCode: en
  title: Codito.dev
  weight: 2
  taxonomies:
    category: categories
    tag: tags
    series: series
  params:
    # Must be set under languages.xx.params since Hugo 0.112 (instead of languages.xx)
    # see: https://gohugo.io/content-management/multilingual/#changes-in-hugo-01120
    languageAltTitle: English

It’s not complete configuration (I’ve omitted menu definition), but it shows the concept - I wanted not only possibility of publishing in two languages, but also full language support in URLs (hence separate taxonomy definitions).

Another important feature I wanted guarantee, is ability to navigate between language versions of the same site. In chosen theme it wasn’t possible out of the box, so I had to override header.html layout, this thread in Hugo’s community platform helped me a lot. But in order to make language switching available, site’s versions must be bound together with translationKey which has to be defined in site’s front matter:

---
title: "Hello World!"
translationKey: "2022-04-10-hello-world"
date: 2022-04-10T01:04:46+02:00
---

Thanks to that, it’s possible to match other language versions for specific page and generate proper links.

Comment system

Minima theme, which I chose, supports Disqus and Utteranc.es. As a reader I used both of these, and each has its own pros and cons. However, having in mind that this site is for technical content, targeting mostly people from IT, I decided to use system based on Github: utteranc.es 🙂

Integration is really simple, so I won’t describe it. There is one detail though, which I wanted to point out - permanent relationship between pages and comments. Because URL addresses can change with time, and support for multiple languages is required, again I chose translationKey for that. Thanks to that, regardless of which language version is displayed and commented, everything will go under the same GitHub issue. We’ll see if this decision was good, but here’s how it was done, with another layout overriding:

<!-- layouts/partials/utterances.html (for Minima theme) -->

<script type="text/javascript">
  const repo = '{{ .Site.Params.utterances.repo }}'
  const issueTerm = '{{ if .Params.translationKey }}{{ .Params.translationKey }}{{ else }}{{ .Site.Params.utterances.issueTerm }}{{ end }}'
  const theme = localStorage.theme ? `github-${localStorage.theme}` : 'preferred-color-scheme';

  const script = document.createElement('script')
  script.src = 'https://utteranc.es/client.js'
  script.async = true
  script.crossOrigin = 'anonymous'

  script.setAttribute('repo', repo)
  script.setAttribute('issue-term', issueTerm)
  script.setAttribute('theme', theme)
  script.setAttribute('label', 'comment')

  document.querySelector('main').appendChild(script)
</script>

Content in sections

Another little detail is related to managing content, because I wanted to treat pages and posts differently. The former I wanted to be available under simplest URLs possible, while the latter with publish date structure.

permalinks:
  posts: '/:year/:month/:slug/'
  pages: '/:slug/'

Such definition ensures that regardless of how I arrange files under content/<language>/posts, in the end they will be available under proper URL.

Worth to mention is setting removePathAccents: true if you want to publish in language that contains special chars that don’t suit URLs well.

Automating deployment

As I said at the beginning, site is served through Cloudflare Pages. Configuration is well documented, process and interface are intuitive. What we need:

  • connect Gitlab / Github account with Cloudflare
  • choose repository from which site will be deployed
  • set project’s name (will be later displayed on projects’ list and will be used for creating *.pages.dev subdomain)
  • configure deployment
    • choose branch used for production deployment
    • set build command and output directory with content to serve
    • set HUGO_VERSION environment variable, it should be the latest one (but most important is that you should use the same version for local development)

The form allows to choose setting from template, so you can choose Hugo. In my case though, I’ve customised settings to reflect multi-language configuration, so I’ve set public/pl as “Build output directory”. Setting hugo --verbose --log --verboseLog --debug as build command might be useful, you’ll get more details from build process.

After approving configuration, deployment will start, and if everything have gone well, site will be available under specific subdomain. Of course, it’s better to use custom domain, especially considering easy and no-cost SSL certificate.

At this stage, each git push to specified branch will trigger deployment process to Cloudflare Pages. It’s possible because Cloudflare creates webhook in source repository, so it gets notified about every commit activity.

Ok, we have one language version deployed, it’s time to configure second one. So again, “Create a project”, choose repository and… we get warning that repository is already in use 😩

Problems Challenges encountered

Cloudflare Pages 1:1 Gitlab Project

It turned out that Cloudflare Pages may use Github / Gitlab repository only once, so it’s still possible to serve multi-language sites, but only with language as URL path’s segment, like https://example.com/pl/. If you want to handle it like me, with multiple domains, additional work is required.

Do you remember what I wrote about overthinking? Really, I wanted to avoid time-consuming research, so I quickly decided to choose first thing that came to my head, and I used Gitlab Pages for english version of the site 😅

I did not want to modify existing Hugo project and split it into two separate projects, so I came up with brilliant idea and in project’s .gitlab-ci.yml I’ve added trigger for other meta-project’s pipeline:

# Trigger build for Gitlab Pages (english version of the site)
gitlab-pages:
  stage: Deploy
  trigger:
    project: codito-net/codito-net.gitlab.io
    branch: main
    strategy: depend
  rules:
    - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH

Gitlab Pages domain convention is pretty straightforward, but considering custom domain that will be used, it does not really matter how we name it. However, personally I like tidiness, so I’ve created codito-net/codito-net.gitlab.io so Gitlab’s technical domain still is as friendly as it could be.

This project contains only Gitlab CI definition:

image: registry.gitlab.com/pages/hugo/hugo_extended:0.96.0

variables:
  GIT_SUBMODULE_STRATEGY: recursive
  HUGO_ENV: production

pages:
  script:
    - apk add --update --no-cache git go
    - git clone --depth 1 --shallow-submodules https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/codito-pl/landing-page.git
    - cd landing-page
    - git submodule update --init --recursive
    - hugo
    # Workaround for: https://gitlab.com/gitlab-org/gitlab-pages/-/issues/668
    - cd ..
    - mkdir public
    - cp -R landing-page/public/en/* public
  artifacts:
    paths:
      - public
  rules:
    # See: https://docs.gitlab.com/ee/ci/triggers/index.html#configure-cicd-jobs-to-run-in-triggered-pipelines
    - if: $CI_PIPELINE_SOURCE == "pipeline"

In the end Gitlab’s pipeline with continuous deployment looks like:

Gitlab Pipeline

Let’s sum it up:

  • gitlab-pages job is only a trigger that starts child pipeline in meta-project. It’s important to set strategy: depend in order to get this job’s status only when child pipeline ends.
  • Cloudflare Pages job on External stage is an automatic Hugo build triggered by webhook, which will deploy polish version of the site to Cloudflare Pages
  • pages job on Build stage in Downstream pipeline builds english version of the site and saves it as artifact, which is then automatically deployed to Gitlab Pages.

Gitlab Pages only with artifact from public

Attentive eye could see that in pages job some workaround was used 😉 Unfortunately at this stage of work I’ve encountered problem with Gitlab’s convention (I’ve used different job’s name myself at the beginning), and also with requirement of publishing from public directory. Problem is reported and most probably will be solved at some point and Gitlab Pages’ publishing will be possible from any directory.

Cloudflare Preview Environment baseUrl

Another challenge was related to Cloudflare Pages and its preview environments’ baseUrl. So long as production domains are hardcoded in the configuration (inside repository), preview environments are created under random subdomain. You can’t workaround that randomness, but you can use environment aliases. Alias is created for each non-production branch, but if you want to make use of it, you must stick to one development branch which will be used for preview deployments. In my case it’s develop branch, so preview environment is available under develop.codito-pl.pages.dev.

“Access policy” comes for the help, you can find it in Cloudflare Pages’ settings:

Cloudflare Pages --> Settings --> General --> Access policy
Cloudflare Zero Trust --> Access --> Applications --> Edit

Having preview environment available under stable address, we can improve build process and set environment’s alias as baseUrl. To be fair, I’ve stuck here for a while, because Hugo’s documentation is not clear enough here. While searching for the solution I’ve found this issue, also I’ve created community thread. In the end I was able to set base URL with HUGO_LANGUAGES_pl_baseurl environment variable, which I’ve set for preview environments:

Cloudflare Pages --> Settings --> Environment Variables

Final proces

After facing all of these challenges I ended with such process:

graph LR; A[Tworzenie treści] -->|git push main| B[Gitlab Pipeline] A[Tworzenie treści] -->|git push develop| G[Gitlab Pipeline] B --> C[Cloudflare External Job] B --> D[Trigger External Pipeline] C -->|Deploy PL| E[Cloudflare Pages] D -->|Deploy EN| F[Gitlab Pages] G --> H[Cloudflare External Job] H -->|Deploy Preview PL| I[Cloudflare Pages]

Probably it’s not ideal, but it does exactly what it’s supposed to. My goal was creating no-cost, two-language site with HTTPS support and automatic deployment process, and it was achieved.

At this stage there are cons:

  • there is no preview environment for english version of the site
  • there is no scheduled deployment, so it’s not possible to create content with publishDate in the future (site is built after git push, so if we push such content to repository it won’t get published until another git push after that date)

Rome wasn’t built in a day 😉