This website is a small, concrete example. Here is how it is built and shipped.
The stack at a glance Link to heading
- Hugo to generate a static site from Markdown files
- hugo-coder as the theme, pulled in as a Git submodule
- Forgejo, self-hosted, for the Git repository, the CI and the container registry
- Docker to package the site as an image served by nginx
- Kubernetes and Flux CD to deploy it, the GitOps way
No database, no CMS, no runtime to patch every week. Just HTML, CSS and a few images.
Why a static site? Link to heading
A personal or company website rarely needs a backend. A static site is:
- fast: files are served straight from disk, and nginx is very good at that;
- secure: there is no application to exploit, only static files;
- cheap: it runs on almost nothing;
- easy to version: every page is a Markdown file in Git, so every change is reviewed, traceable and easy to revert.
Hugo builds the whole site in well under a second, which keeps the feedback loop short when writing.
Self-hosted Forgejo Link to heading
The source code lives on my own Forgejo instance rather than on GitHub. Forgejo is a lightweight, community-driven forge (a fork of Gitea) that provides, in a single binary:
- Git hosting with pull requests and code review;
- Forgejo Actions, a CI system largely compatible with GitHub Actions workflows;
- a package registry, including an OCI/Docker container registry.
Having code, CI and images in one place, under my control, removes several external dependencies and accounts to manage.
Packaging the site as a Docker image Link to heading
The Dockerfile uses a multi-stage build:
FROM debian:bookworm-slim AS build
ARG HUGO_VERSION=0.165.0
ARG TARGETARCH=amd64
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git \
&& curl -fsSL -o /tmp/hugo.deb \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-${TARGETARCH}.deb" \
&& dpkg -i /tmp/hugo.deb \
&& rm -rf /tmp/hugo.deb /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN HUGO_ENVIRONMENT=production hugo --gc --minify
FROM nginx:stable-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/public /usr/share/nginx/html
EXPOSE 80
The first stage installs a pinned Hugo version and generates the site. The second stage only keeps the result on top of a small nginx image. The final image contains no build tools and weighs only a few tens of megabytes.
A short nginx configuration adds gzip compression, cache headers for static assets and Hugo’s custom 404 page.
The CI pipeline Link to heading
The workflow runs on every push, pull request and version tag. It:
- checks out the repository, including the theme submodule;
- builds the image with Docker Buildx;
- pushes it to the Forgejo container registry (except for pull requests, where the image is only built to validate the change).
The tagging strategy is simple:
| Event | Image tags |
|---|---|
Tag v1.2.3 | 1.2.3, latest, sha-<commit> |
Push on main | main, sha-<commit> |
| Pull request | built, not pushed |
latest always points to the last released version, while main follows the development branch. Every image can also be traced back to its exact commit through the sha- tag.
Deploying to Kubernetes with Flux CD Link to heading
The CI stops once the image is in the registry: it has no access to the cluster and holds no Kubernetes credentials. Deployment is handled by Flux CD, following the GitOps model: the desired state of the cluster is described in a Git repository, and Flux, running inside the cluster, continuously reconciles the cluster with it.
The website itself is described by a few plain Kubernetes manifests: a Deployment running the nginx image, a Service, and an Ingress exposing the site over HTTPS. Since the container only serves static files, it needs very little CPU and memory, and running a couple of replicas is enough to survive a node restart without downtime.
To avoid bumping the image version by hand, Flux’s image automation controllers watch the registry:
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: website
namespace: flux-system
spec:
image: git.hio.fr/rb/website
interval: 5m
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: website
namespace: flux-system
spec:
imageRepositoryRef:
name: website
policy:
semver:
range: ">=1.0.0"
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: website
namespace: flux-system
spec:
interval: 30m
sourceRef:
kind: GitRepository
name: flux-system
git:
checkout:
ref:
branch: main
commit:
author:
name: fluxcdbot
email: fluxcdbot@hio.fr
messageTemplate: |
Update website image
{{ range .Changed.Changes }}- {{ .OldValue }} -> {{ .NewValue }}
{{ end }}
push:
branch: main
update:
path: ./apps/website
strategy: Setters
The ImageRepository scans the registry for new tags, the ImagePolicy selects the highest released version, and the ImageUpdateAutomation commits the new tag into the GitOps repository. It looks for a marker in the Deployment and rewrites the image reference next to it:
image: git.hio.fr/rb/website:1.0.0 # {"$imagepolicy": "flux-system:website"}
Note: since the
ImageUpdateAutomationpushes commits to the GitOps repository, the deploy key used by Flux must have write access to it. With a read-only key, Flux detects the new image but fails to push the update.
Flux then applies the change like any other commit. This is why the deployment relies on version tags rather than latest: every deployment is an explicit commit in Git, easy to audit and to roll back with a simple git revert.
Publishing a new version Link to heading
Writing an article and publishing it comes down to:
hugo new content posts/my-new-article.md
# write, preview with `hugo server`, commit, open a pull request
git tag v1.0.1
git push origin v1.0.1
The CI builds the site, packages it and publishes a new 1.0.1 image. A few minutes later, Flux detects it, commits the new version to the GitOps repository and rolls it out to the cluster. No manual step, no SSH, no credentials outside the cluster.
Wrapping up Link to heading
This setup is deliberately modest: a static site generator, a self-hosted forge, a container image and a GitOps deployment. Each piece is simple, replaceable and under control.