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 TokenGIT_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.