For a while I have been using Hugo to generate this site using Gitea Actions to build the image and Helm deploy the site. It was running within my Kubernetes cluster primarily by using a Helm chart I created but always ran into the same issue.
I wanted the ability to create a new post, commit and deploy without having to tag the new image and update the AppVersion in the Helm chart.
That’s it.
Every time I wanted to update the site it would take around 5-10 minutes tagging, waiting for the build, rebuilding the Helm chart, waiting for the pipelines to run just to get my new content out.
I decided all I needed to realistically do was to build the :latest image and provide a way for the cluster to automatically update when it detected a difference between the currently running image and the image in my repo.
Now you might be worried about using the :latest as was I but I decided to go with it for the following reasons:
Simplicity of deployment
Not having to rely on my Gitea repo cleanups to reduce the file size
Finally but most importantly, Hugo is fantastic at failing hard during the build process.
With the last point in hand I know it “should” never build a crap image and as a result the :latest image should be safe. Additionally there is nothing mission critical to to this site so convienience wins out here.
This section will have a lot of detail to show each step. I won’t go into the full details of the Helm chart to keep it simple but if you are interested drop me an email and I might post another post showing how I build the healthcheck and the Helm chart to work together. There is a full directory structure at the end of this post which will give you an idea of roughly what is going on with it.
You could build the image yourself, not use Helm, not build the image with the healthcheck built in. Pick and choose what you need, this is just for reference.
# Use Hugo with extended features as the build stageFROMhugomods/hugo:extsASbuildWORKDIR/srcCOPY . /src# Build the Hugo site using the --minify flag to reduce the size of the generated filesRUN hugo --minify# Use a lightweight nginx image for serving the static filesFROMnginx:alpine# Clear the default nginx HTML directoryRUN rm -rf /usr/share/nginx/html/*# Copy the built Hugo site from the build stage to the nginx HTML directoryCOPY --from=build /src/public /usr/share/nginx/htmlRUN chown -R nginx:nginx /usr/share/nginx/html# Copy the entrypoint script and nginx configuration fileCOPY entrypoint.sh /usr/local/bin/entrypoint.sh# Update the default nginx config to allow for the /health endpointCOPY nginx.conf /etc/nginx/conf.d/default.confCMD ["/usr/local/bin/entrypoint.sh"]
Just to provide some extra detail on what is going on in this script we provide a termination handler which changes the endpoint of the /health from 200 to 503 so that Kubernetes is aware that the pod is about to die so traffic stops being routed to it. This means that users should never get an invalid response while the image is upgraded.
In this stage you can see it builds new changes on the main branch, publishes to my registry using the latest tag and merges the amd64 and arm64 builds into a single manifest to allow any architecture in my cluster to run it.
Although this is Gitea Actions it aligns with GitHub actions and should realistically work the same.
To automatically update the latest image to the most recent version I struggled to find something to do this, but while doing research for a automated dev environment I came across Keel.
Keel is helpful for automatically upgrading images within deployments either on a webhook or an interval. It is build primary for matching against semantic versioning which is helpful for development enviroments and has many other policies listed here.
As I use FluxCD for managing my clusters I created a HelmRelease file as shown below but you could also follow regular Helm setup or other deployment methods here.
I do not normally put these together in a single file but for simplicity they are together below. Additionally you may also see valuesFrom defined in the HelmRelease, this points to a secret for Discord webhooks to notify me on upgrades. There is no functional differences defined in here.
Now we have Keel deployed we can go ahead and modify our deployment in our Helm chart to add some annotations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
apiVersion: apps/v1kind: Deploymentmetadata:
name: {{ include "leeceim.fullname" . }}labels:
{{- include "leeceim.labels" . | nindent 4 }}annotations:
keel.sh/policy: "force"keel.sh/trigger: "poll"keel.sh/match-tag: "true"# keel.sh/pollSchedule: "@every 5m"spec:
{{- if not .Values.autoscaling.enabled }}replicas: {{ .Values.replicaCount }} {{- end }}...
The annotations do the following:
keel.sh/policy: "force" allows us to force update the tag even if it doesn’t match a semantic version.
keel.sh/trigger: "poll" means we want to poll at a set interval rather than wait for a webhook. I may set one up in the future but for now this is the easiest method.
keel.sh/match-tag: "true" means only the exact defined tag in the deployment will be upgraded.
keel.sh/pollSchedule: "@every 5m" although it is commented out we can override the default poll interval which can be defined in the Helm chart.
These could be defined in the values.yaml and templated into the deployment if you wish to do so.
Now we need to make a couple more changes.
In the default Helm template that gets provided with helm create the following is set in the values.yaml
1
2
3
image:
...
pullPolicy: IfNotPresent
Ensure this is set to Always
1
2
3
image:
...
pullPolicy: Always
Finally, ensure the appVersion is set to “latest” in Chart.yaml
1
appVersion: "latest"
Now once the updated Helm chart is deployed to the cluster we should be good to go. Go update the image however you want to do it, pipelines, manually etc… and the new latest image should be fetched within the interval defined or the default provided by Keel. You can check the logs of the Keel pod to see if there are any errors.
By implementing this streamlined deployment pipeline with Keel, I’ve reduced the time it takes to publish new content from 5-10 minutes down to just the time needed to write and commit my changes.
This approach strikes a good balance between simplicity and reliability for a non-critical site like this blog. While using :latest tags in production is often discouraged, Hugo’s strong build validation helps mitigate the risks and the convenience gained is worth it for my use case.
If you have any questions about this setup or suggestions for improvements, feel free to reach out! You can find ways to contact me on my home page.