+++ title = 'Hugo on Kubernetes & NGINX' date = 2024-02-28T15:35:46-08:00 draft = true series = ['wtf'] categories = ['Tutorial'] tags = ['meta', 'k8s', 'flux', 'hugo'] toc = true +++ i decided to make a website. a static one. this one. with [Hugo][hugo]. the main reason i have for this is as a vanity project so i have some stuff to host in a [Kubernetes][k8s] cluster i'm running. the k8s cluster is also as a vanity project. because i don't like software, i wanted a way to deploy my site that doesn't involve much of it. this post is about that. ## Getting Started i built my site by following the straight-forward _[Getting Started][hugo-started]_ guide in the Hugo documentation. i did `hugo new site estradiol.cloud`. and then `cd estradiol.cloud; git init`. and then i picked a ridiculous theme ["inspired by terminal ricing aesthetics"][risotto], installing it like `git submodule add https://github.com/joeroe/risotto.git themes/risotto; echo "theme = 'risotto'" >> hugo.toml`.[^1] [^1]: i appreciate the culinary theme. at this point, my website is basically finished (i also changed the title in `hugo.toml`). i probably won't be putting anything on it, so there's no point fiddling with other details. about deployment, the guide's _[Basic Usage][hugo-deploy]_ page has this to offer: > Most of our users deploy their sites using a CI/CD workflow, where a push{{< sup "1" >}} > to their GitHub or GitLab repository triggers a build and deployment. Popular > providers include AWS Amplify, CloudCannon, Cloudflare Pages, GitHub Pages, > GitLab Pages, and Netlify. > > 1. The Git repository contains the entire project directory, typically excluding the > public directory because the site is built _after_ the push. importantly, you can't make a post about deploying this way. _everyone_ deploys this way. if _i_ deploy this way, this site will have no content. it also involves some system somewhere that can run Hugo to build the site and push it to some remote system where my cluster can reach the compiled site. i definitely already need Hugo installed on my workstation if i'm going to post anything.[^2] so now i'm running Hugo in two places. there's surely going to be other complex nonsense like webhooks involved. [^2]: unlikely. ![diagram: deploy w/ GitHub pages & actions](/images/hugo-github-pages.svg) ---- and hang on. let's look at this again: > 1. The Git repository contains the entire project directory, typically excluding the > public directory because the site is built _after_ the push. you're telling me i'm going to build a nice static site and not check the _actual content_ into version control? couldn't be me. ## Getting Static suppose i instead checked my content into git exactly as i intend to serve it? then i could shell into my server box, pull the site, and _nifty-galifty!_ isn't this the way it has [always been done][worm-love]? my problem is that i don't have a server box. i have a _container orchestration system_. there are several upsides to this[^3] but it means that _somehow_ my generated content needs to end up in a container. because [Pods][k8s-pods] are ephemeral and i'd like to run my site with horizontal scalability[^4], i don't want my container to need to retain runtime state across restarts or replicas. [^3]: few of which could be considered relevant for my project. [^4]: i absolutely will not need this i _could_ run a little pipeline that builds a container wrapping my content and pushes it to a registry somewhere my deployments can pull it. all ready to go. but now i've got _software_ again: build stages and webhooks and, to make matters worse, now i'm hosting and versioning container images. ![diagram: deploy w/ container build](/images/hugo-container-build.svg) i don't want any of this. i just want to put some HTML and static assets behind a web server. --- instead, i'd like to deploy a popular container image from a public registry and deliver my content to it continuously. a minimal setup to achieve this might look like: - a `Pod` with: - an `nginx` container to serve the content; - a `git-pull` sidecar that loops, pulling the git content; - an `initContainer` to do the initial checkout; - an `emptyDir` volume to share between the containers. - a `ConfigMap` to store the nginx config. ![diagram: minimal pod/configmap setup](/images/hugo-minimal-pod-setup.svg) i use `git sparse-checkout` to avoid pulling repository contents i don't want to serve out: ```bash # git-clone command git clone https://code.estradiol.cloud/tamsin/estradiol.cloud.git --no-checkout --branch trunk /tmp/www; cd /tmp/www; git sparse-checkout init --cone; git sparse-checkout set public; git checkout; shopt -s dotglob mv /tmp/www/* /www ``` script up my `git pull` loop: ```bash # git-pull command while true; do cd /www && git -c safe.directory=/www pull origin trunk sleep 60 done ``` and configure `nginx` to use `public/` as root: ```txt # ConfigMap; data: default.conf server { listen 80; location / { root /www/public; index index.html; } } ``` the rest of this is pretty much boilerplate: {{< code-details summary="`kubectl apply -f estradiol-cloud.yaml`" lang="yaml" details=` # estradiol-cloud.yaml apiVersion: v1 kind: ConfigMap metadata: labels: app.kubernetes.io/instance: estradiol-cloud app.kubernetes.io/name: nginx name: nginx-server-block data: default.conf: |- server { listen 80; location / { root /www/public; index index.html; } } --- apiVersion: v1 kind: Pod metadata: name: nginx labels: app.kubernetes.io/instance: estradiol-cloud app.kubernetes.io/name: nginx spec: containers: - name: nginx image: nginx:1.25.4 ports: - containerPort: 80 volumeMounts: - mountPath: /www name: www - mountPath: /etc/nginx/conf.d name: nginx-server-block - name: git-pull image: bitnami/git command: - /bin/bash - -ec - | while true; do cd /www && git -c safe.directory=/www pull origin trunk sleep 60 done volumeMounts: - mountPath: /www name: www initContainers: - name: git-clone image: bitnami/git command: - /bin/bash - -c - | shopt -s dotglob git clone https://code.estradiol.cloud/tamsin/estradiol.cloud.git --no-checkout --branch trunk /tmp/www; cd /tmp/www; p git sparse-checkout init --cone; git sparse-checkout set public; git checkout; mv /tmp/www/* /www volumeMounts: - mountPath: /www name: www volumes: - name: www emptyDir: {} - name: nginx-server-block configMap: name: nginx-server-block ` >}} --- my Hugo workflow now looks like: 1. make changes to source; 1. run `hugo --gc --minify`;[^7] 1. commit & push. the only active process from this point is my little control loop running `git pull`. [^7]: i added `disableHTML = true` to `[minify]` configuration in `hugo.toml` to keep HTML diffs readable. ## Getting Web my Pod is running. everything is great. if i want to browse to my website i just need to setup a [port-forward][k8s-port] TK: YAML counts as software. conveniently, [Bitnami][bitnami] maintains a [Helm][helm] Chart that ![diagram: helm setup](/images/hugo-helm-setup.svg) ## Getting Flux'd by this point i'm pretty `git push`-pilled and i'm thinking i don't much like having this `helm` client software installed on my laptop. ```yaml apiVersion: source.toolkit.fluxcd.io/v1beta2 kind: HelmRepository metadata: name: bitnami namespace: default spec: url: https://charts.bitnami.com/bitnami ``` {{< code-details summary="`release.yaml`" lang="yaml" details=` apiVersion: helm.toolkit.fluxcd.io/v2beta1 kind: HelmRelease metadata: name: web namespace: estradiol-cloud spec: interval: 5m chart: spec: chart: nginx version: '15.12.2' sourceRef: kind: HelmRepository name: bitnami namespace: default interval: 1m values: cloneStaticSiteFromGit: enabled: true repository: "https://code.estradiol.cloud/tamsin/estradiol.cloud.git" branch: trunk gitClone: command: - /bin/bash - -ec - | [[ -f "/opt/bitnami/scripts/git/entrypoint.sh" ]] && source "/opt/bitnami/scripts/git/entrypoint.sh" git clone {{ .Values.cloneStaticSiteFromGit.repository }} --no-checkout --branch {{ .Values.cloneStaticSiteFromGit.branch }} /tmp/app [[ "$?" -eq 0 ]] && cd /tmp/app && git sparse-checkout init --cone && git sparse-checkout set public && git checkout && shopt -s dotglob && rm -rf /app/* && mv /tmp/app/* /app/ ingress: enabled: true hostname: estradiol.cloud ingressClassName: nginx tls: true annotations: { cert-manager.io/cluster-issuer: letsencrypt-prod } serverBlock: |- server { listen 8080; root /app/public; index index.html; } service: type: ClusterIP `>}} {{ with .Resources.GetMatch "hugo-flux-seq.svg" }} {{ end }} ## A Note About Software at this point i'm forced to admit there's still a lot of software involved in this. setting aside the stuff that provisions and scales my cluster nodes, i have: - `nginx` (running from a stock image); - `git` & `bash` (running from a stock image); - a remote git server (i'm running `gitea`[^8], but github dot com is fine here); - Kubernetes (oops!); - `fluxcd`; - especially `kustomize-controller` and `helm-controller`; - `nginx-ingress` controller; - the `bitnami/nginx` Helm chart; [^8]: because i'm running `gitea` in my cluster and i want to avoid a circular dependency for my `flux` source repository, i also depend on GitLab dot com. i get to maintain my two `bash` scripts for `git-clone` and `git-pull`, my NGINX config, and a couple of blobs of YAML. at least there are no webhooks. --- _fin_ [bitnami]: https://bitnami.com/ [helm]: https://helm.sh [hugo]: https://gohugo.io [hugo-deploy]: https://gohugo.io/getting-started/usage/#deploy-your-site [hugo-started]: https://gohugo.io/getting-started [k8s]: https://kubernetes.io [k8s-init]: https://kubernetes.io/docs/concepts/workloads/pods/init-containers/ [k8s-pods]: https://kubernetes.io/docs/concepts/workloads/pods/ [k8s-port]: https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/ [k8s-pv]: https://kubernetes.io/docs/concepts/storage/persistent-volumes/ [risotto]: https://github.com/joeroe/risotto [worm-love]: https://www.mikecurato.com/worm-loves-worm