2026 Blog CI build updates

date:

tags: Hugo git markdown blog Gitlab CI Gitlab

categories: Software Development

I have not touched the CI / CD pipeline in Gitlab that deploys this blog in several years. It was set up with a project that is now deprecated: Kaniko. The project built a Dockerfile (container image) without requiring root access or the docker socket mounted somewhere to build a container. In the years since I set up this blog, other projects have become available to build containers. I ended up switching to the buildkit project which I believe is directly from the company behind Docker.

GitLab CI/CD is a feature of the GitLab platform that is used in software development for Continuous Integration, Delivery, and Deployment of software. The idea is that software source code will grow and evolve over time but you do not want to have to manually build/compile and test your code each time a change is made. This is especially true when you develop software as a team and multiple changes might be made in a single day.

Gitlab Pipeline

My new pipeline also does not require root access or mounting the Docker socket. I am still using Gitlab to host the source code for my blog and as a CI/CD system to build and deploy my blog as a container image. My blog is a static site generated by Hugo. I create markdown files for blog posts and I created a theme that customizes the style of the website. Hugo takes these as input and generates a website of static HTML, CSS, and JS files that can be hosted on any web server.

For the rest of this post I will outline the two files that are needed to build the container and use Gitlab CI.

The pipeline is configured in .gitlab-ci.yml. Here is the file at the time of the post:

---
stages:
  - build
buildTesting:
  stage: build
  image:
    name: moby/buildkit:rootless
    entrypoint: [""]
  variables:
    GIT_SUBMODULE_STRATEGY: recursive
    BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
  before_script:
    - mkdir -p ~/.docker
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > ~/.docker/config.json
  script:
    - |
      buildctl-daemonless.sh build \
        --frontend dockerfile.v0 \
        --local context="$CI_PROJECT_DIR" \
        --local dockerfile="$CI_PROJECT_DIR" \
        --output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true
  rules:
    - if: '$CI_COMMIT_BRANCH == "testing"'
buildProduction:
  stage: build
  image:
    name: moby/buildkit:rootless
    entrypoint: [""]
  variables:
    GIT_SUBMODULE_STRATEGY: recursive
    BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
  before_script:
    - mkdir -p ~/.docker
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > ~/.docker/config.json
  script:
    - |
      buildctl-daemonless.sh build \
        --frontend dockerfile.v0 \
        --local context="$CI_PROJECT_DIR" \
        --local dockerfile="$CI_PROJECT_DIR" \
        --output type=image,name=$CI_REGISTRY_IMAGE:latest,push=true
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Pipeline Structure

The CI pipeline has one stage:

  • build: Creates Docker images using BuildKit

Jobs

buildTesting

  • Trigger: Runs on commits to the testing branch
  • Purpose: Builds and pushes a Docker image tagged with the commit SHA
  • Image tag: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

buildProduction

  • Trigger: Runs on commits to the main branch
  • Purpose: Builds and pushes a Docker image tagged as latest
  • Image tag: $CI_REGISTRY_IMAGE:latest

Build Process

Both jobs use the same build process:

  1. Image: Uses moby/buildkit:rootless for secure, daemonless Docker builds
  2. Submodules: Recursively clones git submodules (required for the Hugo theme)
  3. Authentication: Configures Docker registry credentials using GitLab CI variables
  4. Build: Uses BuildKit’s buildctl-daemonless.sh to build the Dockerfile
  5. Push: Automatically pushes the built image to the GitLab Container Registry

Environment Variables

The pipeline uses these GitLab CI built-in variables:

  • CI_REGISTRY: GitLab Container Registry URL
  • CI_REGISTRY_USER: Registry username (automatically provided)
  • CI_REGISTRY_PASSWORD: Registry password (automatically provided)
  • CI_REGISTRY_IMAGE: Full image name including registry path
  • CI_COMMIT_SHA: Git commit hash for tagging testing builds
  • CI_PROJECT_DIR: Project directory path

Docker Build

Here is the Dockerfile I am using at the time of this post:

# syntax=docker/dockerfile:1.4

# Use Debian slim image with Go and necessary tools
FROM debian:bookworm-slim AS hugo

# Install Hugo extended dependencies
RUN apt-get update && apt-get install -y \
    curl \
    git \
    ca-certificates \
    libsass-dev \
    && rm -rf /var/lib/apt/lists/*

# Download Hugo Extended binary
ENV HUGO_VERSION=0.153.3
RUN curl -L -o hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_Linux-64bit.tar.gz \
    && tar -xzf hugo.tar.gz \
    && mv hugo /usr/local/bin/hugo \
    && chmod +x /usr/local/bin/hugo

WORKDIR /site
COPY . .

# Build the Hugo site
RUN hugo --minify

# Final stateless NGINX image
FROM nginx:alpine

COPY --from=hugo /site/public /usr/share/nginx/html

The Dockerfile creates a multi-stage build:

  1. Hugo stage: Downloads Hugo Extended, builds the static site
  2. NGINX stage: Serves the built site using NGINX Alpine

Deployment

After the pipeline completes:

  • Testing builds: Available as $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  • Production builds: Available as $CI_REGISTRY_IMAGE:latest

Since the project is public, anyone can pull and run these images from the project’s GitLab Container Registry.

The container image that is built will start an nginx web server that serves the static pages generated by Hugo in the pipeline.

New disclaimer: I used an LLM to help create this post. Opinions expressed are likely from me and not the LLM.

comments powered by Disqus