ellie@princess ~ $ bat ~/blog/how-this-site-works.md
cd ../blogHow this site works: a tiny static site in a surprisingly serious trenchcoat
This website looks like a small terminal window that wandered into a pastel cabinet. That is the fun part. The less visible part is that it is built like a production service: reproducible static build, minimal container, private registry, pinned deployments, restricted Kubernetes namespace, Gateway API routes, and OpenTofu state living in a Rook Ceph object store.
This is perhaps excessive for a personal website. That is also the point.
A static site is one of the nicest places to practice infrastructure discipline because the application itself is brutally simple. There is no database migration trying to eat your evening. No websocket fleet. No “just one background worker” that secretly wants to become a platform. The whole runtime requirement is: please serve these files over HTTP without doing anything cursed.
So this post is a tour of the whole thing. Not just the Hugo bits in this repository, but also the deployment estate in /Users/ellie/talos/opentofu-apps/augustini-wtf, because that repo is the other half of the creature. One repo makes the artifact. The other repo tells the cluster how to run it.
The shape of the system
There are two repositories involved:
augustini.wtf: the website source, Hugo theme, Tailwind source CSS, Woodpecker pipeline, and scratch-image Dockerfile.talos/opentofu-apps/augustini-wtf: the OpenTofu workspace that creates Kubernetes resources and Gateway API routes for the site.
The deployment path looks like this:
1git push
2 -> Woodpecker builds Hugo site
3 -> Tailwind emits the final theme CSS
4 -> Hugo writes public/
5 -> Docker Buildx packages public/ with static-web-server
6 -> Harbor receives latest and a timestamp tag
7 -> Woodpecker updates Terrakube variable image_tag
8 -> Terrakube runs the OpenTofu workspace
9 -> Kubernetes Deployment rolls to the pinned image
10 -> Gateway API sends augustini.wtf traffic to the Service
The design goal is boring in the good way: the website repo should not need cluster credentials, my laptop should not be the deployment authority, and Kubernetes should not run an image named latest while everyone politely pretends that is a version.
Hugo: the part that makes pages
The site is Hugo with a local theme named techprincess. The root hugo.toml is intentionally conventional:
1baseURL = 'https://augustini.wtf/'
2title = 'Ellie Augustini'
3theme = 'techprincess'
4enableRobotsTXT = true
5summaryLength = 24
Content lives under content/: home, about, blog, projects, and individual project/blog pages. Hugo’s content model does most of the routing without ceremony. A post under content/blog/foo.md becomes a blog page. A project under content/projects/foo.md becomes a project page. The theme layouts decide how those sections feel.
The theme is very much not a generic “minimal blog” theme. It is a terminal UI cosplay machine, but with the useful parts kept and the fake-terminal nonsense mostly avoided. The base layout wraps the page in a bordered panel with a title bar, three colored dots, a scanline overlay, and then regular semantic HTML inside it:
1<body class="min-h-screen bg-background text-foreground font-mono antialiased">
2 <a class="skip-link" href="#main">...</a>
3 <div class="...">
4 <div class="... border border-border bg-card ...">
5 <div class="... border-b border-border bg-secondary/60 ...">
6 <span class="... bg-pink"></span>
7 <span class="... bg-gold"></span>
8 <span class="... bg-mint"></span>
9 <span>ellie@princess: ~</span>
10 </div>
11 <div class="scanlines">
12 {{ partial "header.html" . }}
13 <main id="main" tabindex="-1">
14 {{ block "main" . }}{{ end }}
15 </main>
16 </div>
17 </div>
18 {{ partial "footer.html" . }}
19 </div>
20</body>
There is a real skip link. The main region is focusable. The terminal aesthetic is allowed to be cute, but it is not allowed to eat the accessibility basics.
The theme: one font, many little constraints
The visual system is all Iosevka, all the time. The font files are self-hosted from themes/techprincess/static/fonts/, so the finished site does not ask Google Fonts, Bunny, Adobe, or some random CDN for permission to render text.
The full Iosevka latin woff2 files are about a megabyte each, which is silly for a site whose pages are a few hundred kilobytes. After Hugo writes public/, a small Python step (scripts/subset-fonts.py, run via npm run subset-fonts) walks every rendered .html, collects the set of characters actually used, and runs pyftsubset over each source font to produce a subset covering exactly those glyphs (plus a small safety set for the footer clock and CSS content bullets). The source fonts stay untouched; only public/fonts/ is rewritten. The result is roughly 4 MB of fonts shrinking to about 195 KB total, with no visible difference.
Tailwind is used as a compiler and constraint system, not as a browser dependency. package.json has the important scripts:
1{
2 "scripts": {
3 "build:css": "tailwindcss -i themes/techprincess/assets/css/source.css -o themes/techprincess/assets/css/main.css --minify",
4 "subset-fonts": "python3 scripts/subset-fonts.py",
5 "build": "npm run build:css && hugo --minify && npm run subset-fonts",
6 "serve": "npm run build:css && hugo serve --disableFastRender"
7 }
8}
The Tailwind config scans Hugo content and layouts, the theme JS, and both Hugo config files. It also enables Iconify’s dynamic selectors so the templates can use classes like icon-[lucide--heart] and icon-[simple-icons--forgejo] without manually vendoring SVGs into every partial.
The palette is expressed as OKLCH tokens in source.css:
1:root {
2 --background: 0.16 0.012 300;
3 --foreground: 0.92 0.015 330;
4 --card: 0.2 0.015 305;
5 --primary: 0.8 0.16 350;
6 --accent: 0.86 0.16 168;
7 --pink: 0.82 0.17 352;
8 --mint: 0.86 0.16 168;
9 --gold: 0.87 0.15 92;
10 --lilac: 0.8 0.12 300;
11}
Tailwind maps those into named colors:
1colors: {
2 background: 'oklch(var(--background) / <alpha-value>)',
3 foreground: 'oklch(var(--foreground) / <alpha-value>)',
4 pink: 'oklch(var(--pink) / <alpha-value>)',
5 mint: 'oklch(var(--mint) / <alpha-value>)',
6 gold: 'oklch(var(--gold) / <alpha-value>)',
7 lilac: 'oklch(var(--lilac) / <alpha-value>)',
8}
That means the layouts can stay readable: border-pink/60, text-muted-foreground, bg-secondary/50, and so on. No component library. No design token runtime. Just CSS variables, Tailwind’s build step, and a strong preference for rectangles with manners.
Hugo pipes: cache-busted assets without a bundler-shaped monster
The theme uses Hugo’s asset pipeline for the final CSS and JS includes.
In development, CSS is linked directly:
1{{- with resources.Get "css/main.css" }}
2 {{- if hugo.IsDevelopment }}
3 <link rel="stylesheet" href="{{ .RelPermalink }}">
4 {{- else }}
5 {{- with . | fingerprint }}
6 <link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" crossorigin="anonymous">
7 {{- end }}
8 {{- end }}
9{{- end }}
In production, Hugo fingerprints it and adds an integrity attribute. The JavaScript gets the same treatment after passing through js.Build with minification enabled outside development.
The browser JS is intentionally tiny. It does two things:
- Adds the
darkclass to the document element. - Adds copy buttons to Chroma code blocks, stripping line number spans before writing text to the clipboard.
There is also a small inline footer clock. This means the live browser behavior is not zero JavaScript, but it is measured in teaspoons. There is no client router, hydration pass, state manager, or background analytics ritual.
Syntax highlighting: Chroma, but dressed for the room
Hugo uses Chroma for syntax highlighting. The config keeps classes enabled:
1[markup.highlight]
2 noClasses = false
3 style = 'monokai'
4 lineNos = true
5 lineNumbersInTable = false
6 tabWidth = 2
The style = 'monokai' line mostly stops being the story once noClasses = false is set. Hugo emits semantic token classes, and the theme CSS decides what those classes mean.
Keywords become mint. Types and tag names become pink. Functions become gold. Strings become lilac. Comments get shoved into the quiet corner at muted 60 percent opacity, as comments deserve. Line numbers are non-interactive, unselectable spans, which matters because copy buttons clone the code block and remove .lnt and .ln before copying.
The result is syntax highlighting that belongs to the same visual world as the rest of the site. It does not look like somebody taped a Dracula code block onto a different website and fled the scene.
Woodpecker: from source tree to artifact
The CI pipeline is .woodpecker/publish.yml. It runs on pushes and manual events.
The first step uses node:22-bookworm-slim, because the build needs npm for Tailwind and Hugo for the static site. Hugo is installed explicitly from GitHub releases:
1hugo_version='0.162.1'
2case "$(uname -m)" in
3 x86_64) hugo_arch='linux-amd64' ;;
4 aarch64|arm64) hugo_arch='linux-arm64' ;;
5 *) echo "Unsupported CI architecture: $(uname -m)" >&2; exit 1 ;;
6esac
7curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v${hugo_version}/hugo_extended_${hugo_version}_${hugo_arch}.tar.gz" |
8 tar -xz -C /usr/local/bin hugo
Then:
1npm ci
2npm run build
3test -f public/index.html
4date -u +%Y%m%d%H%M%S > .release_tag
5printf 'latest,%s\n' "$(cat .release_tag)" > .tags
That .tags file is for the Docker plugin. Every build gets latest for convenience and a UTC timestamp tag for deployment. The timestamp is the important one. latest is a bookmark. The timestamp is a version.
The container: static-web-server inside scratch
The image build is wonderfully rude:
1FROM ghcr.io/static-web-server/static-web-server:latest AS sws-binary
2
3FROM scratch
4
5COPY --from=sws-binary /static-web-server /sws
6COPY ./public /public
7
8ENV SERVER_PORT=8080
9ENV SERVER_ROOT=/public
10
11EXPOSE 8080
12
13ENTRYPOINT ["/sws"]
It copies the static-web-server binary out of the upstream image, copies Hugo’s public/ directory, and then runs from scratch.
There is no shell. No package manager. No CA bundle unless the binary and app need one, which this site does not for serving local files. No busybox escape hatch. If someone gets arbitrary command execution inside this container, their prize is a filesystem containing a web server binary and some HTML. It is the infrastructure equivalent of opening a safe and finding a polite note that says “no.”
Static Web Server listens on 8080 and serves /public. The site is pure static output, so it does not need an application runtime. The container is not where logic lives. The container is where files wait to be sent.
The publish-image Woodpecker step uses woodpeckerci/plugin-docker-buildx, pushes to registry.augustini.xyz/sites/augustiniwtf, and targets linux/amd64.
The handoff: Woodpecker pokes Terrakube, not Kubernetes
The deploy step is where the two repos shake hands.
Woodpecker does not run kubectl apply. It does not carry a kubeconfig. It does not patch a Deployment directly. Instead, it calls the Terrakube API.
The script:
- Reads
.release_tag. - Finds organization
v0cloud. - Finds workspace
augustini-wtf. - Finds Terraform variable
image_tag. - Patches that variable to the new timestamp.
- Finds the template named
Plan and apply. - Creates a Terrakube job for that workspace/template pair.
This is the most important boundary in the deployment. Woodpecker is allowed to produce an artifact and request a deployment. Terrakube is the thing allowed to evaluate and apply infrastructure.
That gives a cleaner audit trail: source build in Woodpecker, stateful infrastructure action in Terrakube, actual reconciliation in Kubernetes.
After deployment, Woodpecker also prunes Harbor artifacts. It keeps five non-latest artifacts with timestamp tags matching ^[0-9]{14}$, deletes older artifacts by digest, and sweeps any already-untagged artifacts left behind by previous cleanup runs. This is housekeeping, not heroism, but housekeeping is how registries avoid becoming museums of forgotten builds.
OpenTofu: the other repo
The OpenTofu workspace in /Users/ellie/talos/opentofu-apps/augustini-wtf is small, which is exactly what you want for an app layer.
It uses OpenTofu >= 1.12.0, < 1.13.0 and the HashiCorp Kubernetes provider ~> 3.1.0. The lock file pins provider checksums. The provider config supports both local validation and Terrakube execution:
1provider "kubernetes" {
2 config_path = var.kubeconfig_path != "" ? var.kubeconfig_path : null
3}
Locally, set kubeconfig_path. In Terrakube, leave it empty and let the runner pod authenticate in-cluster through its ServiceAccount.
State is not local, and Terrakube is not pretending to be the state backend. The backend is S3-compatible storage served by Rook Ceph RGW inside the cluster:
1terraform {
2 backend "s3" {
3 bucket = "tofu-state"
4 key = "augustini-wtf/terraform.tfstate"
5 region = "us-east-1"
6
7 endpoints = {
8 s3 = "http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc:80"
9 }
10
11 use_path_style = true
12 skip_credentials_validation = true
13 skip_region_validation = true
14 skip_metadata_api_check = true
15 skip_requesting_account_id = true
16 }
17}
That tells you quite a lot about the platform around the app: this is a Kubernetes-native setup, running on Talos, with Terrakube runners inside the cluster and Rook Ceph providing object storage for state. The app workspace does not define Talos itself, Rook itself, Terrakube itself, or the public Gateway. It attaches to those platform primitives.
That separation is healthy. App repo says “run this site.” Platform repos say “here is the cluster, object storage, runner identity, gateway, DNS automation, and so on.”
Variables: small escape hatches, tight defaults
The OpenTofu variables are boring in the pleasing sense.
The resource base name and namespace default to augustini-wtf. The image repository defaults to registry.augustini.xyz/sites/augustiniwtf. The image tag has a validation rule:
1variable "image_tag" {
2 description = "Pinned container image tag."
3 type = string
4 default = "20260607072706"
5
6 validation {
7 condition = var.image_tag != "latest"
8 error_message = "image_tag must be pinned to a concrete tag instead of latest."
9 }
10}
That single validation block prevents the classic personal-site incident where latest works until the registry, node cache, rollout controller, and your assumptions all form a small committee and vote against you.
The Harbor credentials are sensitive variables. The registry host defaults to registry.augustini.xyz. Replica count defaults to 2, with validation requiring at least one. CPU and memory defaults are intentionally tiny:
1requests = {
2 cpu = "10m"
3 memory = "32Mi"
4}
5limits = {
6 cpu = "250m"
7 memory = "128Mi"
8}
This is a static web server. If it needs more than that under normal personal-site traffic, something is either famous or on fire.
Kubernetes: the runtime contract
The OpenTofu module creates a namespace with common labels:
1"app.kubernetes.io/name" = var.name
2"app.kubernetes.io/part-of" = "talos"
3"app.kubernetes.io/managed-by" = "terrakube"
Then it merges in Pod Security Admission labels:
1"pod-security.kubernetes.io/enforce" = "restricted"
2"pod-security.kubernetes.io/audit" = "restricted"
3"pod-security.kubernetes.io/warn" = "restricted"
That means the namespace is not merely “please be secure” flavored. It asks Kubernetes to enforce the restricted profile. The pod spec then matches that intent:
1automount_service_account_token = false
2
3security_context {
4 run_as_non_root = true
5 run_as_user = 10001
6 run_as_group = 10001
7 fs_group = 10001
8
9 seccomp_profile {
10 type = "RuntimeDefault"
11 }
12}
The container security context tightens the rest:
1allow_privilege_escalation = false
2read_only_root_filesystem = true
3run_as_non_root = true
4
5capabilities {
6 drop = ["ALL"]
7}
This lines up nicely with the scratch image. The image has nothing interesting to mutate, and Kubernetes makes the root filesystem read-only anyway. The pod gets no service account token by default. Linux capabilities are gone. Privilege escalation is off. Seccomp uses the runtime default.
It is a very small box with a very small job.
The Deployment exposes one named container port:
1port {
2 name = "http"
3 container_port = 8080
4 protocol = "TCP"
5}
Readiness and liveness probes both hit / through that named port. The Service maps port 80 to target port http, giving the Gateway a stable backend target without making the container pretend it is allowed to bind privileged ports.
Registry auth: dockerconfigjson as infrastructure
Because the image lives in private Harbor, the module creates a kubernetes.io/dockerconfigjson secret:
1data = {
2 ".dockerconfigjson" = jsonencode({
3 auths = {
4 (var.harbor_url) = {
5 username = var.harbor_username
6 password = var.harbor_password
7 auth = base64encode("${var.harbor_username}:${var.harbor_password}")
8 }
9 }
10 })
11}
Then the Deployment references it via image_pull_secrets.
This is a good example of infrastructure code being allowed to be unglamorous. The cluster needs credentials to pull the image. Terrakube owns the sensitive inputs. OpenTofu renders the exact Kubernetes secret. The pod uses it. Nobody needs to click around in a registry UI and hope the namespace has the right secret forever.
Gateway API: routing as an app-owned resource
The app does not define a load balancer. It attaches routes to an existing Gateway named main-gateway in the default namespace.
For the apex hostname, the route attaches to section https-wtf-apex and sends / to the Service:
1hostnames = [var.hostname]
2
3parentRefs = [
4 {
5 group = "gateway.networking.k8s.io"
6 kind = "Gateway"
7 name = var.gateway_name
8 namespace = var.gateway_namespace
9 sectionName = var.gateway_https_section
10 }
11]
The route also sets HSTS:
1filters = [
2 {
3 type = "ResponseHeaderModifier"
4 responseHeaderModifier = {
5 set = [
6 {
7 name = "Strict-Transport-Security"
8 value = "max-age=63072000; includeSubDomains; preload"
9 }
10 ]
11 }
12 }
13]
There is a separate www route attached to https-wtf that returns a 301 redirect to the apex host. There is also an optional HTTP route, enabled by default, that attaches to the Gateway’s http section and redirects both augustini.wtf and www.augustini.wtf to HTTPS on the apex hostname.
This is where Gateway API feels cleaner than the old Ingress pile. The platform owns the Gateway and listeners. The app owns its hostnames, redirects, headers, and backend references. The contract between them is explicit: route attaches to named listener section, listener accepts it, traffic moves.
ExternalDNS is controlled through route annotations. If publishing is enabled, the route gets:
1"external-dns.alpha.kubernetes.io/include" = "true"
If disabled, it gets a controller annotation pointing at an ignore value. That gives the app workspace a switch for “publish records from these routes” without making DNS a manual side quest.
The Talos part, and what this repo does not own
The OpenTofu labels say part-of = talos, and the path says the same thing: this app belongs to a Talos-based cluster estate. Talos matters here less because of any one line in the app module and more because of the operational model it implies.
Talos is not a general-purpose Linux host that also happens to run Kubernetes. It is an immutable Kubernetes appliance OS. No SSH as the normal management interface. No “just apt install htop on the node.” No artisanal node drift. Cluster operations go through APIs and declared configuration.
That philosophy matches the site deployment:
- The app container is scratch.
- The pod has a read-only root filesystem.
- The namespace enforces restricted Pod Security.
- The deployment is driven by OpenTofu from Terrakube.
- State lives in object storage, not on a laptop.
- Routes attach to a pre-existing platform Gateway.
The website is tiny, but the operational shape is the same one you want for larger services: artifacts are built once, deployed by tag, reconciled by controllers, and exposed through platform-owned ingress.
What actually changes when I publish a post
When this post gets merged and pushed, the pipeline does not mutate the cluster directly. It creates a new immutable-ish artifact and moves one pointer.
The only deployment variable Woodpecker changes is image_tag. That variable goes from one timestamp to another. Terrakube runs the plan and apply. Kubernetes sees the Deployment pod template image change from:
1registry.augustini.xyz/sites/augustiniwtf:20260607072706
to something like:
1registry.augustini.xyz/sites/augustiniwtf:20260607123456
That pod template change triggers a rollout. New pods pull the new image using the Harbor secret. Readiness probes pass. The Service selector keeps pointing at the same labels. The Gateway route keeps pointing at the same Service. Traffic gradually finds the new pods.
There is no “deploy content” step. The content is inside the image. That makes rollback boring too: set image_tag back to an older timestamp, apply, and let Kubernetes roll back to pods serving that older public/ tree.
Why this is funny and why it is not
Yes, this is a lot of machinery for a personal blog with a heart icon in the header.
But the machinery is not there because the website needs compute. It is there because I want the deployment path to teach the right habits:
- Build static output deterministically.
- Package the runtime as a small container.
- Push to a registry.
- Deploy pinned tags.
- Keep infrastructure state remote.
- Let CI request deployment, not impersonate the cluster admin.
- Run with fewer privileges than the app could ever need.
- Put routing, redirects, and headers in declarative resources.
The actual app is almost aggressively simple: HTML, CSS, a tiny amount of JavaScript, and a Rust static file server. The platform around it is the part that makes the simplicity durable.
That is the nice thing about static sites. You can make the runtime so small that all the interesting engineering moves to the edges: how assets are built, how images are tagged, who is allowed to apply infrastructure, where state lives, how traffic enters, and what the pod is forbidden from doing.
The result is a terminal princess website that deploys like a grown-up service and runs like a locked filing cabinet with a web server taped to it. It is pastel. It is strict. It contains almost no moving parts. It sparks joy, and then immediately drops all Linux capabilities.
back to all field notes