how this site runs
This site is a static page served by nginx from a single-node Kubernetes cluster, which runs in a virtual machine on a server in my homelab. The only public address in front of it belongs to a RAM-only relay in Switzerland, which forwards traffic to the cluster over WireGuard. One repository defines all of it, from the host's firewall to the container image and the Deployment, in about 12,500 lines of Terraform that CI applies, with the state kept in HCP Terraform. The repository is the source of truth: changes are made there and applied from there, never against the cluster by hand.
The sections below start with the machine, follow a request in through the relay, follow one change from a commit to a running pod, and describe the platform it lands on.
AI wrote the HTML/CSS on this page. I do infrastructure, not frontend.
the machine
the host
The cluster runs in one virtual machine on a Proxmox VE host, and Terraform manages the host too: its package repositories, its firewall, the storage pool, the Talos ISO, the VM and its nightly backup job. It signs in with an API token, never over SSH. The host boots with Secure Boot, its firewall accepts connections only from the local network, and every VM disk lives on a ZFS pool that is encrypted at rest and unlocked by hand after each boot.
the node
Talos Linux runs in that VM. It has no shell, no SSH and no package manager, so every change is a declarative machine config applied over an mTLS API. The VM boots UEFI with Secure Boot and a virtual TPM. The state and ephemeral partitions use LUKS2 sealed to that TPM, and enrolment checks that Secure Boot is on. The data volumes are sealed the same way, with a recovery passphrase in a second slot. The ISO and the installer come from one Image Factory schematic, whose ID is a hash of its contents, and the ISO is pinned by checksum. Point the installer at the stock image instead and Talos removes every system extension when it writes to disk. Each virtual disk carries a fixed serial number, and Talos picks its disks by serial, never by their order on the bus.
the cluster
control plane
The cluster is one node running both the control plane and workloads, with scheduling on control-plane nodes enabled. Terraform generates the machine secrets, applies the config, bootstraps etcd and keeps the resulting kubeconfig in state.
networking
Cilium provides the CNI and enforces NetworkPolicy in eBPF. Each application namespace starts from default deny, with its exceptions written next to the others. The policies select workload identities rather than only CIDR blocks, which allows a rule to match the node's own host identity, and a cluster-wide rule keeps the relay from reaching any pod. Hubble serves the flow data behind single sign-on.
storage
local-path provisions a host-path volume per pod, and bulk data gets its own class on a separate disk. SeaweedFS serves S3-compatible object storage from the VM's remaining disks. Every one of those volumes is encrypted. One node has no second failure domain, so a replicated pool would not add durability; the nightly backup of the whole VM does.
the relay
the instance
Every public hostname resolves to one small instance on Infomaniak's public cloud in Switzerland. It boots a custom Alpine image and runs entirely from RAM, so nothing it handles is written to disk. It has no SSH, no login console and no syslog. The root account is locked at boot, the configuration arrives on a config drive, and once nftables and WireGuard are up the relay disables kernel module loading until it shuts down. Its public address belongs to a separate network port, so the address survives a rebuild of the instance.
forwarding
nftables forwards a fixed list of ports to the node over WireGuard: the web, SSH for git and two game servers. Everything else is dropped. The node dials out to the relay and keeps the tunnel open, so nothing at home has to accept a connection from outside. Forwarded connections are masqueraded, so the cluster sees every visitor as the relay's tunnel address, and no visitor's IP reaches nginx, the logs or anything else at home. TLS terminates on the node, so the relay holds no certificate and never sees inside an HTTPS request. The node refuses to route anything that arrives through the tunnel, and the relay's counters answer only the node, over the tunnel.
the relay image
A build script assembles the image from Alpine's virt ISO, with every package the relay needs carried on it, so it boots without reaching a package mirror. When the build or its overlay changes, CI builds and uploads a new image, checks the upload against the cloud's own hash, and pins the new image ID in git. The same pipeline's apply then replaces the instance. The relay is never patched in place: changing it means shipping a new image or new configuration.
ingress and dns
ingress and certificates
ingress-nginx runs as a DaemonSet on the host network and receives whatever the relay forwards. There is no load balancer. cert-manager holds one ClusterIssuer per domain and solves DNS-01 challenges against Cloudflare, so no ACME validation traffic reaches the cluster.
dns
Cloudflare serves DNS and nothing else. Every record is a DNS-only A record pointing at the relay, so no request passes through Cloudflare and nothing is cached at an edge. CAA records allow only Let's Encrypt, and only through DNS-01. Universal SSL is switched off, because Cloudflare would otherwise add CAA records for its own certificate authorities.
the image
the registry
Images live in GitLab's own container registry, which runs on the same cluster at artifacts.vinnel.cloud, one repository per site. Pods authenticate with a project deploy token mounted as an imagePullSecret rather than a personal one. The node's machine config resolves the registry's name to the node itself, so image pulls never leave the machine. Base images arrive through a pull-through cache at mirror.vinnel.cloud, so a rebuild does not re-fetch them from Docker Hub.
build stages
The image builds in four stages, each base pinned by digest. The first converts the source PNG into a multi-resolution .ico and an apple-touch icon, and squares the avatar down to a 288-pixel WebP. The second compiles ngx_brotli against the exact nginx version in the base image, because upstream publishes no compatible release. The third builds the assets. The fourth is nginx-unprivileged and runs as a non-root user.
assets
The web font is downloaded during the build and served from this origin, and the avatar is committed rather than hotlinked, so nothing third-party loads at runtime and the Content-Security-Policy stays at default-src 'self'. CSS and JavaScript are minified and renamed to include a hash of their contents, with every reference rewritten to match; that hash is what the one-year immutable cache header applies to. Text files are compressed once at build time with gzip -9 and brotli -q 11.
serving
nginx
nginx serves the pre-compressed files directly, so it never compresses a static file per request; only the proxied blog responses are compressed on the fly. Hashed assets get immutable cache headers and open_file_cache keeps hot file descriptors open. Every response carries HSTS, CSP, nosniff, frame and referrer policy headers. /healthz returns plain text for the probes, .html URLs redirect to their extensionless form, and the old /posts/ addresses and /chat redirect to their new homes on blog.vin.moe and chat.vin.moe. The same pods answer all three hostnames, with one server block per host. The blog host serves only the reader page, its assets and the public post endpoints, and the chat host only the chat page, its assets and the runner scripts. chat.vin.moe's front page answers curl and wget with a shell script, PowerShell with a PowerShell script and everything else with a page, and its Vary: User-Agent header keeps any cache from handing one to the other. /beta does the same for the beta builds.
the page
The page uses no framework and no bundler. On first load it requests only the stylesheet, one script, the font and the avatar. The other tabs fetch their content the first time they open: the blog from posts.json, and work, chat and the colophon from their own standalone pages, whose main section is dropped into the tab. One palette covers both themes through light-dark(), so the toggle changes a single property. The tabs are an ARIA tablist with arrow-key navigation and fragment links, and the pink block marking the current tab slides to the next one when you switch. Posts render into details elements for inline reading, and each one also has its own page on blog.vin.moe, rendered on the server so a shared link works without JavaScript. With JavaScript disabled the tab bar is hidden and the about section renders in full. The other sections link to their standalone pages, and the blog to the feed.
the deployment
Two replicas sit behind a ClusterIP Service. Each requests 50m CPU and 16Mi of memory, with limits of 200m and 64Mi. Containers run as non-root with all capabilities dropped, no privilege escalation and the RuntimeDefault seccomp profile. Startup, readiness and liveness probes call /healthz. Rollouts surge to 100% with zero unavailable pods and a pre-stop drain, a PodDisruptionBudget keeps one replica serving, and a VerticalPodAutoscaler adjusts memory within that band.
posts
writing and reading
Posts are written in an editor at admin.vinnel.cloud, behind the same single sign-on as everything else, and stored on a volume beside it. The blog tab requests /posts.json, nginx proxies that path to the editor over the cluster network, and the markdown is rendered there rather than at build time. A new post therefore appears without rebuilding or redeploying anything.
the durable copy
Publishing also commits the markdown to the integration branch through the GitLab API, and CI opens the merge request. The repository keeps the durable copy and the volume serves the live one. Public post responses carry an ETag and tell browsers to revalidate on every load, so an edit shows on the next view and an unchanged post costs only a 304.
deploying a change
validation
Every change starts on the integration branch. CI opens a merge request into production and arms auto-merge, and GitLab merges it once that pipeline passes. A merge request runs only the checks its paths call for. A change to a script runs the shared build-script test suite against a synthetic page, which covers the asset-hashing and cache-busting logic every site's build.sh has in common. The infrastructure plan skips the paths Terraform does not read, so editing HTML does not queue an apply on the node that also serves the page.
build and digest
buildctl pushes the :latest and commit-SHA tags against a registry-hosted layer cache, so unchanged stages, including the compiled nginx module, are pulled rather than rebuilt. The sha256 digest it returns, not the mutable tag, is recorded in a JSON file of image digests and committed by the job that built it. On a push race the retry resets to whatever landed first and records its digest again: two sites deploying at once write neighbouring lines of the same file, and a rebase would hit the same conflict on every attempt.
rollout
The rollout reads the digest back from git at the remote tip, so it deploys what was recorded rather than what a job variable held. It sets the image to that exact digest and waits on the rollout status.
reconcile and mirror
terraform apply needs only the digest-recording step, not the rollout. Both read the same committed digests, so the imperative rollout and the declarative apply converge on the same image reference whichever finishes first. The apply runs in its own CI pod: HCP Terraform only stores the state, and the job decrypts its inputs from a SOPS-encrypted file in the repository with CI's own key. Commits authored by CI push with the project's job token, which GitLab excludes from triggering a pipeline, and carry a [skip ci] marker as well. A final job pushes the site source to a public GitHub repository through a scoped token, committing only when something changed.
ci runners
the runners
Two gitlab-runner Deployments run in the cluster, both using the Kubernetes executor, neither sharing a Docker socket. One takes untagged jobs at a concurrency of 4. The other takes only jobs tagged privileged-build, so an ordinary lint or test job cannot land on a pod running privileged. Each job gets its own pod built from the image it declares, and no daemon survives between jobs. The runner config is a Secret, and a config-hash annotation restarts the Deployment when it changes.
the runner image
The runner image is built on debian:trixie-slim, pinned by digest, with the toolchain baked in: node, gh, jq, rsync, kubectl, tflint, shellcheck, yamllint and build-essential. It carries nothing a pipeline does not run, because every extra binary is more vulnerability surface for the nightly scan to trip over. Jobs use its mutable :latest tag, so the kubelet pulls it again on every pod start rather than keeping the first copy it pulled, and every rebuild takes effect, including the ones the vulnerability scan triggers. Because that tag decides what CI runs, a rebuild is smoke-tested before it is allowed to become :latest.
registry access from jobs
Public names resolve to the relay, so a job pushing an image would otherwise leave the house and come back in through the tunnel. A hostAliases entry on the runner's executor config points the registry, mirror and GitLab hostnames at the node instead. It lives there rather than on individual jobs, so every job pod inherits it, and a firewall rule opens the web ports to the pod network to match.
observability
grafana
Grafana sits over VictoriaMetrics for metrics, Loki for logs and Tempo for traces, and signs in through Authelia. vmagent discovers targets by pod annotation, so a new workload becomes observable by adding two annotations, with no scrape config to edit, and an OpenTelemetry collector on the node ships every pod's logs. ingress-nginx exports OTLP spans, so a slow request and the logs behind it belong to one trace.
alerting
Alert rules live in the repository rather than in an interface. The chart's default rules cover the node and the workloads, such as a node going NotReady or a Deployment short of its replicas, and two rules of my own catch a certificate expiring within 14 days and a volume over 85% full. The default rules and dashboards are fetched from upstream at pinned commits, so an upstream change cannot alter them unannounced. Alertmanager sends email and renotifies every four hours while an alert is still firing. A node that stops responding also stops alerting, so that check cannot live here: Better Stack polls from outside, and status.vinnel.cloud frames its status page.
resilience
failure testing
Chaos Mesh deletes a random site pod every 30 minutes and stresses one hourly, which exercises the disruption budget and the probes above against real failures rather than assumptions. It is scoped to the per-site namespaces, and never touches authentication, the mesh, or the alerting meant to catch problems.
backups
Proxmox Backup Server, on the host, backs up the cluster's whole virtual machine every night at 03:00 to MEGA's S4 object storage in Luxembourg. It is encrypted on the host before it leaves, so the bucket only ever holds ciphertext, and the host's storage key can touch only objects, not buckets. Backups are incremental and deduplicated, and a week of dailies and a month of weeklies are kept. Each Saturday a job re-verifies any snapshot not checked in the last 30 days. A backup holds the whole machine, its disks, EFI variables and TPM state included.
security
the firewall
The Talos host firewall defaults to deny. The forwarded ports accept only the relay's tunnel subnet and the local network, and the web ports are also open to the pod network for the jobs above. The Kubernetes and Talos APIs cannot be reached from the internet at all: they accept the local network, where Terraform runs from a workstation, and the pod network, where CI runs. On the relay, both the cloud's security group and nftables allow only WireGuard, ping and the forwarded ports.
access
Admin interfaces sit behind Authelia two-factor authentication. The development machine is reachable only over a self-hosted WireGuard mesh, never the public internet. Secrets that Terraform can generate are generated and held in state. The rest, mostly API tokens, sit in the repository in a SOPS file encrypted to two post-quantum age keys, one mine and one CI's. CI deploys with a token scoped to patching Deployments in the two site namespaces. Namespaces run at Talos's default baseline Pod Security level, except a handful whose workloads need host access, and the runner pods, which need privileged mode for BuildKit and are kept apart from ordinary jobs by runner tag. The public blog endpoints are the one unauthenticated path into a service that is otherwise behind single sign-on, so they are served from a route that only reads published posts and never reaches the editing API.
supply chain
Base images and Terraform providers are pinned by digest or lockfile, so moving a tag cannot change what ships. Every merge request and production push runs gitleaks over the full history, plus go vet and the Go tests, shellcheck and yamllint when it touches the files they check; terraform fmt and tflint run before the plan they gate. Grype scans the runner image nightly, and a fixable high or critical finding triggers a rebuild. Trivy Operator scans every image running in the cluster and alerts on critical vulnerabilities and exposed secrets. Renovate opens the version bumps, grouped so a dozen digest updates arrive as one merge request, and major updates wait for review.