Or is it

No (of course not)

This whole thing runs on kubernetes using the k3s distribution and is managed using Flux for gitops. At some other time I’ll go into further details on how that all works. Here I’ll just focus on the parts closer into getting Hugo running. Kustomize to provide further templating.

I swear that kubernetes is at the same place in its lifecycle that JavaScript was at 10 years ago. A million different solutions to transpile and bundle your code because the end result is not something anyone wants to touch directly. Anyway…

Step 1: A webserver

The web server being used is Ingress NGINX Controller. Certificates are managed using cert-manager. Metallb is being used as a load balancer. There’s a lot here that I won’t go into detail now but when requests come in on port 80 or 443 they get routed to the address metallb assigned the ingress controller which handles the SSL portion and proxies the request to the hugo service.

Step 2: A Hugo

Here’s the directory structure of the service definition:

1
2
3
4
5
6
7
.
├── app
│   ├── hugo.env
│   ├── hugo.yaml
│   └── kustomization.yaml
├── ks.yaml
└── kustomization.yaml

The top level kustomization.yaml is trivial and just simple resource lists like this

1
2
3
4
5
6
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - app
  - ks.yaml

The ks.yaml is a flux Kustomization

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
  name: hugo
  namespace: flux-system

spec:
  path: './cluster/andor/apps/public/hugo/app/'
  interval: 1m0s
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system

  decryption:
    provider: sops
    secretRef:
      name: sops-gpg

  postBuild:
    substituteFrom:
      - kind: ConfigMap
        name: global-vars

      - kind: ConfigMap
        name: hugo-site-version

      - kind: Secret
        name: global-secrets

This ensures I can use variables in the definition of hugo.

The other kustomization.yaml file is a little more interesting. This one creates a config map for me

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - hugo.yaml

generatorOptions:
  disableNameSuffixHash: true

configMapGenerator:
- name: hugo-site-version
  namespace: flux-system
  envs:
  - hugo.env

This reads hugo.env

1
HUGO_VERSION=e8dd92a014a9c7be9a4f0132241d1644953a7805

And creates a config map like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
❯ kubectl -n flux-system get configmaps hugo-site-version -o yaml
apiVersion: v1
data:
  HUGO_VERSION: e8dd92a014a9c7be9a4f0132241d1644953a7805
kind: ConfigMap
metadata:
  creationTimestamp: "2023-08-10T23:57:47Z"
  labels:
    kustomize.toolkit.fluxcd.io/name: hugo
    kustomize.toolkit.fluxcd.io/namespace: flux-system
  name: hugo-site-version
  namespace: flux-system
  resourceVersion: "34262521"
  uid: 7791cb6e-ccf9-4550-bc1c-658a031e28e3

The wonderful helm chart by bjw-s is used for creating a helm release of a docker image.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: hugo
  namespace: public

spec:
  chart:
    spec:
      chart: app-template
      version: 1.5.1
      reconcileStrategy: ChartVersion
      sourceRef:
        kind: HelmRepository
        name: bjw-s
        namespace: flux-system

  interval: 5m0s
  install:
    createNamespace: true

  values:
    image:
      repository: code.example.com/org/hugosite
      tag: ${HUGO_VERSION}
      pullPolicy: IfNotPresent

    controller:
      strategy: RollingUpdate
      replicas: 2

    service:
      main:
        type: LoadBalancer
        ports:
          http:
            enabled: true
            port: 80
            targetPort: 80
            protocol: TCP

    ingress:
      main:
        enabled: true
        ingressClassName: "nginx"
        annotations:
          hajimari.io/icon: simple-icons:hugo

        hosts:
          - host: &host "hugo.${SECRET_DOMAIN2}"
            paths:
              - path: /
                pathType: Prefix

        tls:
          - secretName: wildcard2-cert-tls
            hosts:
              - *host

    resources:
      requests:
        cpu: 20m
        memory: 125M


      limits:
        cpu: 50m
        memory: 256M

The image tag is defined as ${HUGO_VERSION}. This value comes from hugo.env. And given that this is a Rube Goldberg machine no way am I setting this by hand.

Step 3: The CD (continuous delivery)

Continuous delivery for Hugo is managed using a self-hosted gitea instance. That’s a separate topic and I’m not sold on my own hosted solution for this because of bootstrap problems. But, for Hugo I think it isn’t too egregious. It has a great package registry. The action runner isn’t designed for kubernetes and has some rough edges. But I have it working.

Here’s the Dockerfile for hugo that I’m using.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
FROM alpine:3.9 AS build

# The Hugo version
ARG VERSION=0.113.0

ADD https://github.com/gohugoio/hugo/releases/download/v${VERSION}/hugo_${VERSION}_Linux-64bit.tar.gz /hugo.tar.gz
RUN tar -zxvf hugo.tar.gz
RUN /hugo version

# We add git to the build stage, because Hugo needs it with --enableGitInfo
RUN apk add --no-cache git

# The source files are copied to /site
COPY . /site
WORKDIR /site

# And then we just run Hugo
RUN /hugo --minify --enableGitInfo

# stage 2
FROM nginx:1.15-alpine

WORKDIR /usr/share/nginx/html/

# Clean the default public folder
RUN rm -fr * .??*

# Finally, the "public" folder generated by Hugo in the previous stage
# is copied into the public fold of nginx
COPY --from=build /site/public /usr/share/nginx/html
RUN chown -R 100:101 /usr/share/nginx/html

At this time this container image is 7.7 MiB. That’s a nice lean container. 😄

Here’s the workflow file in the hugo repository:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
name: Build and push image
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

    container:
      image: catthehacker/ubuntu:act-latest

    steps:
      - name: Checkout repo
        uses: actions/checkout@v3
        with:
          submodules: recursive

      - name: Log in to the Container registry
        uses: docker/login-action@v2
        with:
          registry: code.example.com
          username: name
          password: ${{ secrets.REGISTRY_TOKEN }}

      - name: Docker build and push
        run: |
          docker build --build-arg GIT_COMMIT=$(git rev-parse HEAD) -t org/hugosite:$(git rev-parse HEAD) --progress=plain .

          docker tag org/hugosite:$(git rev-parse HEAD) code.example.com/org/hugosite:$(git rev-parse HEAD)
          docker image push code.example.com/org/hugosite:$(git rev-parse HEAD)

          docker tag org/hugosite:$(git rev-parse HEAD) code.example.com/org/hugosite:latest
          docker image push code.example.com/org/hugosite:latest

      - name: Checkout homelab
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.ID_RSA }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa

          echo "${{ secrets.ID_RSA_PUB }}" > ~/.ssh/id_rsa.pub
          chmod 644 ~/.ssh/id_rsa.pub

          echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts
          chmod 644 ~/.ssh/known_hosts

          git clone git@gitlab.com:name/homeserver.git

      - name: Set hugosite version
        run: |
          echo "HUGO_VERSION=$(git rev-parse HEAD)" > homeserver/cluster/andor/apps/public/hugo/app/hugo.env

          git config --global user.email "name@example.com"
          git config --global user.name "name (CI bot)"

          cd homeserver
          git add cluster/andor/apps/public/hugo/app/hugo.env
          git commit -m "Hugo version update from CI"
          git push origin main

I’ve replaced some references here with generic example and name but you see what’s happening. The docker image is being built and pushed to the private registry. And then the hash of the last git commit is written out to the hugo.env file in the homelab repo (which kustomize turns into a configmap) and that’s pushed up which flux will make go live. The pipeline takes 14 seconds to run (which is great) but because of how I have flux setup it can take more than 5 minutes for updates to be reflected live.

Is It Working?

Yes. Here’s a performance chart.

Grafana request chart

Conclusion

All this for a blog I’ll update twice.