Introduction

In April of 2022, I migrated this blog from Ghost to Hugo. Hugo is an open-source static site generator. A static site means that there is no server-side code being executed. The site is built from its Markdown source into the HTML, CSS, Javascript, images, and other resources which can be served up by a simple web server. That also means that it can be entirely cached by a Content Delivery Network (CDN) such as Cloudflare, making it fast and resistent to wide variety of attacks.

I’ve taken this a step further by automating the publishing of the blog by building a container and deploying that container onto a Kubernetes cluster using Drone.

Getting Started with Hugo

It’s pretty straight forward to get Get Started with Hugo. You only need to install Hugo and Git.

Once installed, create the new site and initialize it as a git repository:

$ hugo new site my-site
$ cd my-site
$ git init

To add a theme to the site, add it as a git submodule. I’m using the Hello Friend theme.

$ git submodule add -f https://github.com/panr/hugo-theme-hello-friend.git themes/hello-friend

For Hugo to use the new theme, it needs to be added to the config.toml file:

theme = "hello-friend"

To view the new site in the browser, run Hugo in server mode and enter http://localhost:1313 in the browser:

$ hugo server -D

Hugo server will monitor the site files for changes and rebuild the site. This is great for seeing the changes immediately while writing posts.

Archetypes

To create a new post, I use the new command:

$ hugo new content/posts/new-post.md

Archetypes allow me to create a template for new posts. Each new post has a section for additional metadata, called Front Matter, such as description, cover image, tags, categories, and publication date.

archetypes/posts.md:

---
title: "{{ replace .Name "-" " " | title }}"
slug: "{{ .Name }}"
author: "Lachlan"
date: {{ .Date }}
description: ""
categories: ["changeme","changeme2"]
tags: ["changeme","changeme2"]
draft: true
---

Dev, Test, and Production

Rather than a simple config.toml file in the root of the site, I’ve created a more complicated set up that let’s me customize the configuration based on a variable which can be set at build time.

The base configuration is in config/_default/config.toml and contains common configuration entries such as the name of the site, menus, and theme:

config/_default/config.toml:

<snip>
title = "Life of Lachlan"
theme = "hello-friend"
paginate = 5
enableGitInfo = true
[languages]
  [languages.en]
    title = "Life of Lachlan"
    contentDir = "content/"
<snip>

The environment-specific configuration entries go into a separate config.toml.

config/dev/config.toml:

baseURL = "http://localhost:1313/"
buildDrafts = "true"
buildFuture = "true"

config/production/config.toml:

baseURL = "https://lachlanlife.net/"
buildDrafts = "false"
buildFuture = "false"

Hugo will use the environment variable HUGO_ENV_ARG to determine which configuration to use for the current build.

$ export HUGO_ENV_ARG=production
$ hugo

This will build the production site in the public directory. There are a number of options for publishing and deploying a Hugo site, but I’m building and deploying a standalone container with nginx serving the static content.

Configuring Git

Before publishing, I want to add the site to a Gitea repository. First, I want to exclude some directories and files from being committed to the repository.

.gitignore:

public/*
.vscode

First commit and push:

$ git add .
$ git commit -m "Initial commit"
$ git remote add origin git@gitssh.domain.tld/path/to/repository
$ git push origin master

Building the Container

In order to deploy the entire site onto a Kubernetes cluster or in Docker, the site must be built into a container. The specifics on how to do that are defined in the Dockerfile:

ARG HUGO_ENV_ARG=production
#  Adds contents Dockerfile folder into /src and builds to the /target
# https://github.com/klakegg/docker-hugo
FROM klakegg/hugo:0.93.2-ext-onbuild AS site

# start a new image based on the nginx container
FROM nginx:alpine

# Ensure this is not cached
ARG CACHEBUST=1

# copy the built site to the site directory
COPY --from=site /target /usr/share/nginx/html
EXPOSE 80/tcp

By default, it will build the production version of the site, but I can also pass the name of a different environment using –build-arg in the “docker build”.

The first step uses Docker Hugo to build the site into a container called “site”. The second step creates a new container with nginx and copies the contents built in the “site” container. Nginx is a web server and reverse proxy which will serve the static content on port 80.

To build a local version of the container using Docker, update any submodules and build the container. I’m passing the current date in seconds to make sure that Docker doesn’t cache any of the layers.

$ git submodule update --init --recursive
$ docker build --build-arg HUGO_ENV_ARG=dev --build-arg CACHEBUST=$(date +%s) -t blog:dev .

To run the container on port 1313 in the same way that “hugo server -D” would (without the automatic rebuilds):

$ docker run --rm -p 1313:80 --name blog blog:dev

Publishing

If I were publishing the site by hand, the next step would be to push the container to a container registry such as Docker Hub or my self-hosted registry, I would tag the image and push it:

$ docker build --build-arg HUGO_ENV_ARG=production --build-arg CACHEBUST=$(date +%s) -t registry.domain.tld:port/username/repo:latest 
$ docker push registry.domain.tld:port/username/repo:latest

If I were just going to run the production container on a normal Docker host:

$ docker pull registry.domain.tld:port/username/repo:latest 
$ docker run --rm -p 80:80 --name blog registry.domain.tld:port/username/repo:latest 

Or, using docker-compose:

version: "2.1"
services:
  blog:
    image: registry.domain.tld:port/username/repo:latest
    container_name: blog
    ports:
      - 80:80
    restart: unless-stopped

and

$ docker-compose up -d

Automating with Drone

With Drone, I’m automating the process of building and deploying the blog to the test site and then I can promote that deployment to production using the Drone web interface. I’ve already deployed Drone and Gitea including configuring the OAuth2 application so that Gitea repositories are seen by Drone and the appropriate webhooks are created. I’ve also installed drone-runner-kube and the Kubernetes secrets extension for running Drone pipelines on Kubernetes.

Note: While writing this post, I noticed that the Kubernetes runner has been deprecated. The recommendation is to use the Docker runner instead, but I have not looked into it how this differs from the kube runner.

Inside of the site’s git repository, the drone.yml file defines the pipelines and steps to build the site. I’ll explain each section separately.

.drone.yml:

---
kind: secret
name: registry
get:
  path: registry
  name: url
---
kind: secret
name: registry_username
get:
  path: registry
  name: username
---
kind: secret
name: registry_password
get:
  path: registry
  name: password
---
kind: secret
name: registry_email
get:
  path: registry
  name: email

This section gets the secret values from a Kubernetes secret in the drone namespace which was deployed when we deployed Drone. Alternatively, these values can defined in the Drone web interface in the repository settings.

.drone.yml (cont’d):

---
kind: pipeline
type: kubernetes
name: stage

trigger:
  branches:
    - master
  event:
    - push

This section defines a pipeline called stage which is triggered whenever there is a push to the master branch of the git repository. The steps to build the image, verify the helm chart, and install the help chart are defined next.

.drone.yml (cont’d):

steps:
- name: Update git submodules
  image: alpine/git
  commands:
  - git submodule update --init --recursive
- name: Build Staging image
  image: plugins/docker
  settings:
    build_args:
      - HUGO_ENV_ARG=staging
    username:
      from_secret: registry_username
    password: 
      from_secret: registry_password
    repo: registry.domain.tld/username/repo
    registry: registry.domain.tld:port
    tags:
    - staging-latest
    - staging-${DRONE_COMMIT_SHA:0:7}
    - staging-${DRONE_COMMIT_BRANCH}

The build step executes the Docker build and tags the image with tags for staging, staging with the commit SHA, and staging with the branch name.

.drone.yml (cont’d):


# https://github.com/pelotech/drone-helm3
- name: lint
  image: pelotech/drone-helm3
  settings:
    mode: lint
    chart: ./helm/chart

The lint step verifies that the helm chart in the repository is valid. The helm chart is a whole other post on its own.

.drone.yml (cont’d):

- name: Install
  image: pelotech/drone-helm3
  environment:
    REGISTRY:
      from_secret: registry
    REGISTRY_USERNAME:
      from_secret: registry_username
    REGISTRY_PASSWORD:
      from_secret: registry_password
    REGISTRY_EMAIL:
      from_secret: registry_email
  settings:
    mode: upgrade
    chart: ./helm/chart
    release: blog-staging
    namespace: blog-namespace
    false: true
    wait: true
    skip_tls_verify: true
    secrets: 
      - registry
      - registry_username
      - registry_password
      - kubernetes_token
      - kubernetes_certificate
    kube_service_account: drone-deployer
    kube_api_server: https://kubernetes.default.svc.cluster.local:443
    kube_token:
      from_secret: kubernetes_token
    kube_certificate: 
      from_secret: kubernetes_certificate
    values: 
      - image.tag=staging-${DRONE_COMMIT_SHA:0:7}
      - image.repository=registry.domain.tld/username/repo
      # When using secrets, can't use curly braces for arbitrary environment vars
      - imageCredentials.registry=$REGISTRY
      - imageCredentials.username=$REGISTRY_USERNAME
      - imageCredentials.password=$REGISTRY_PASSWORD
      - imageCredentials.email=$REGISTRY_EMAIL

The install step uses the helm chart to install the application in the cluster, passing the parameters to the chart. I’ve created a Kubernetes service account in the blog namespace and saved the resulting kubernetes_token and kubernetes_certificate as secrets in the Drone repository settings.

.drone.yml (cont’d):

- name: notify-result
  image: plugins/webhook
  settings:
    urls:
    - https://ntfy.domain.tld/drone-alerts
    template: |
       {{#success build.status}}
         Build {{build.number}} succeeded and deployed to Staging! :)
         Event: {{build.event}}
         Branch: {{build.branch}}
         Tag: {{build.tag}}
         Git SHA: {{build.commit}}
         Link: {{build.link}}
       {{else}}
         Build {{build.number}} failed and not deployed to Staging :(
         Event: {{build.event}}
         Branch: {{build.branch}}
         Tag: {{build.tag}}
         Git SHA: {{build.commit}}
         Link: {{build.link}}
       {{/success}}       
  when:
    status: [ success, failure ]

The final step uses a webhook to send a notification to ntfy about the success or failure.

The rest of .drone.yml defines the production pipeline and is very similar to staging, but is only executed when a staging build is promoted to production.

.drone.yml (cont’d):

---
kind: pipeline
type: kubernetes
name: prod

trigger:
  event:
    - promote
  target:
    - production

steps:
- name: Update git submodules
  image: alpine/git
  commands:
  - git submodule update --init --recursive
- name: Build Production Image
  image: plugins/docker
  settings:
    build_args:
      - HUGO_ENV_ARG=production
    username:
      from_secret: registry_username
    password: 
      from_secret: registry_password
    repo: registry.domain.tld/username/repo
    registry: registry.domain.tld:port
    tags:
    - latest
    - ${DRONE_COMMIT_SHA:0:7}
# https://github.com/pelotech/drone-helm3
- name: Install
  image: pelotech/drone-helm3
  environment:
    REGISTRY:
      from_secret: registry
    REGISTRY_USERNAME:
      from_secret: registry_username
    REGISTRY_PASSWORD:
      from_secret: registry_password
    REGISTRY_EMAIL:
      from_secret: registry_email
  settings:
    mode: upgrade
    chart: ./helm/chart
    release: blog
    namespace: blog-namespace
    debug: true
    wait: true
    skip_tls_verify: true
    secrets: 
      - registry
      - registry_username
      - registry_password
      - kubernetes_token
      - kubernetes_certificate
    kube_service_account: drone-deployer
    kube_api_server: https://kubernetes.default.svc.cluster.local:443
    kube_token:
      from_secret: kubernetes_token
    kube_certificate: 
      from_secret: kubernetes_certificate
    values: 
      - image.tag=${DRONE_COMMIT_SHA:0:7}
      - image.repository=registry.domain.tld/username/repo
      # When using secrets, can't use curly braces for arbitrary environment vars
      - imageCredentials.registry=$REGISTRY
      - imageCredentials.username=$REGISTRY_USERNAME
      - imageCredentials.password=$REGISTRY_PASSWORD
      - imageCredentials.email=$REGISTRY_EMAIL
- name: notify-result
  image: plugins/webhook
  settings:
    urls:
    - https://ntfy.domain.tld/drone-alerts
    template: |
       {{#success build.status}}
         Build {{build.number}} succeeded and deployed to Prod! :)
         Event: {{build.event}}
         Branch: {{build.branch}}
         Tag: {{build.tag}}
         Git SHA: {{build.commit}}
         Link: {{build.link}}
       {{else}}
         Build {{build.number}} failed and not deployed to Prod! :(
         Event: {{build.event}}
         Branch: {{build.branch}}
         Tag: {{build.tag}}
         Git SHA: {{build.commit}}
         Link: {{build.link}}
       {{/success}}       
  when:
    status: [ success, failure ]

Summary

Except for the helm chart that’s used to deploy the blog to the Kubernetes cluster itself, this is everything needed to build a brand new Hugo site, manage it with git, and publish it. The helm chart consists of many parts and I will share it in a future post.