Building the Blog
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 [email protected]/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.