oranki.net

// TODO:


Container image for a Hugo static site on Kubernetes

I’ve recently moved almost all of my self-hostings under Kubernetes, k3s to be specific. One of the things that’s not so nice on Kubernetes is serving static sites. Since there’s no “regular” NGINX in front of the services anymore, serving a static site would basically need a separate web server container running behind ingress-NGINX. It’s a perfectly valid way to serve a static site, but I wanted something a bit more automated.

I first thought about writing a simple Go webserver binary to serve the files, but then got thinking that Hugo itself has the --serve (or --server) flag to serve the site. So, why not use that as the server?

Implementing this was simple, I went for an Alpine base image though it probably could have worked just as well with a FROM scratch image. The dockerfile looked like this:

FROM alpine

RUN apk add --no-cache hugo

WORKDIR /site

CMD ["hugo","serve","--disableLiveReload","--disableFastRender","--minify","--bind","0.0.0.0","-b","https://oranki.net/","--appendPort=false"]

Then all that’s necessary is to mount the directory containing the Hugo site files under /site and run the image.

I did this for a few months, but then after making a couple changes to the site config, I started thinking if the process could be automated a bit further. In the spirit of saving maybe ten seconds once a month, the image evolved during the following couple hours.

First, the Dockerfile got a couple changes:

FROM alpine

RUN apk add --no-cache hugo git
RUN adduser -SD -g Hugo -s /bin/ash hugo && \
    mkdir /site && chown hugo:nogroup /site
COPY ./docker-entrypoint.sh /

USER hugo
WORKDIR /site

CMD ["/docker-entrypoint.sh"]

The referenced docker-entrypoint.sh looks like the following:

#!/bin/ash

URL=https://${GIT_USERNAME}:${GIT_TOKEN}@${GIT_REPO}
git clone "${URL}" .

exec hugo serve \
    --disableLiveReload \
    --disableFastRender \
    --minify \
    --bind "0.0.0.0" \
    -b "${DOMAIN}" \
    --appendPort=false

As you can see, there are a couple variables that need to be defined:

  • GIT_USERNAME: the username to use for authenticating to Git. It may be that this can be anything when using Personal Access Tokens.
  • GIT_TOKEN: the created Personal Access Token
  • GIT_REPO: this is the HTTP path to the repo, without the protocol prefix. E.g. github.com/user/my-blog.git
  • DOMAIN: The public domain of the site with the http/https prefix

I opted to define GIT_USERNAME and GIT_TOKEN as secrets for k3s in the same namespace, the rest are just plain environment variables in the pod deployment YAML.

What happens, is the repo is cloned from git first on startup, using HTTP basic authentication (https://${GIT_USERNAME}:${GIT_TOKEN}@...). GIT_TOKEN is an access token with read-only rights to private repos on Forgejo. After cloning the repo, the site is rendered and served from memory by Hugo. Newer hugo versions don’t have --renderToMemory flag anymore, this is the default now. Everything is executed as non-root user, for a bit of extra security.

The process for publishing a new post, like this one, is now to write the post, commit and push the changes to git, then restart the container. Or delete the pod in Kubernetes’ case, a new one is created automatically for the deployment or statefulSet.

If you want to build the image yourself, the above Dockerfile and docker-entrypoint.sh are all that’s needed. Optionally, there’s a ready image available to pull at git.oranki.net/jarno/hugo-server:latest. This image is multiarch, and should run on x86_64 and arm64 architectures.

A note about hosting your site repo on Github

Github’s access token system is complicated, and this image only works with a “classic” token with the “repo” scope. That token also gives push permissions to private repos.