2026-10-29
Nine months to a boring platform
An earlier version of this post claimed the foundation took days. Git disagrees. The first
commit is ac900836 on 2026-01-19, the repository holds over three thousand commits, and
one afternoon in July I deleted every application namespace at once.
Getting to a stack this ordinary took nine months.
January: one host, applied by hand
The cluster began as virtual machines on a single host that I configured by clicking. Talos
stuck from the first week, because it hands you an API instead of a shell. Rook arrived on
2026-01-21 (539137e5) with its charts copied into the repository, since a single-node
Ceph install needed patches that upstream would not take. Gitea joined two weeks later
(3cda697e, 2026-02-03) next to Argo CD.
Storage came first, the forge second, automation last. Every later experiment needed both, and the order saved me a rewrite.
For the first months the repository was a diary with manifests in it. That habit is why I can still name the day each thing broke.
February: a network and a reconciler
Cilium replaced the default CNI on 2026-02-12 (509e31e8), installed from Terraform. Argo
CD arrived on 2026-02-03 (3cda697e) and deployed its first workload six days later
(f2f43a8d). Tailscale showed up on 2026-02-21 (d68c688d) and turned a LAN cluster into
something I could reach from another country.
March: I ruined a cluster
The commit from 2026-03-08 (13235e0e) reads: "Sadly, the cluster got ruined during
kubespan deployment, cluster was recreated." Cilium and KubeSpan fought over routing, and I
could not debug the failure yet, so I rebuilt the cluster.
That same week the Oracle node started as a manual OCI Terraform deployment (2d93b31e).
By 2026-03-20 a multi-site Traefik deployment spanned Oracle, worker-infra and worker-svt
(5df84cec). Tailscale carried the cross-region link without a VPN server.
On 2026-03-21 native routing with KubeSpan landed (e28c795c). This configuration is the
least obvious part of the stack, and both halves are required:
# Talos machine config
machine:
kubespan:
enabled: true
advertiseKubernetesNetworks: true
# Cilium
routingMode: native
bpf:
masquerade: true
hostLegacyRouting:
true # required: eBPF host routing bypasses the kernel
# routing table and conflicts with KubeSpan
MTU returns to 1500 once the VXLAN header is gone, and remote-node masquerade stays enabled for nodes behind another NAT. Sidero documents the Cilium constraint in the KubeSpan guide. I found it by losing a cluster first.
Rebuilding Ceph that week rebuilt its storage classes too. I renamed rook-ceph-block to
ceph-block (7e374cad, e7a42375), and the claims pointing at the old name came back
bound to nothing. Gitea and JupyterHub moved to the new class the same day (7e374cad,
ba52ef71) and started with empty volumes.
May: Argo CD out, Flux in
With Argo CD I spent my time inventing an app-of-apps convention and debugging sync waves.
Flux ships one opinionated layout and a dependency graph. On 2026-05-20 the migration
landed (2b236920): apps/, infrastructure/ and clusters/, reconciled as sources then
secrets then controllers then configs then apps.
Velocity improved. Consistency improved more, because the same pattern repeated across
forty applications and nothing outside the graph could exist. The history records the
wobble: on 2026-05-12, eight days before the migration, a commit message says "convert Flux
to ArgoCD" (33ab5fb9).
One day before that switch, external-dns went in (aa17aa59, 2026-05-19). I had never heard
of the project. It watches Gateway and HTTPRoute objects and writes the matching Cloudflare
records itself, so a hostname exists because an object exists. The Gateway carries the
public IP as an annotation (2f72e37c, 2026-05-29) and the records follow it when the
address changes. Hand-written DNS entries had been the last thing I edited outside git, and
that chore disappeared in an afternoon.
July: one line deleted every namespace
On 2026-07-29 I removed ../../apps/namespaces/ from infrastructure/configs (5302803c).
That listing had let the configs Kustomization own every application namespace. When I
force-deleted the Kustomization, Flux pruned the namespaces, and Kubernetes removed
everything inside them. The whole application layer went with it.
Three fixes followed in two days. I made the namespaces Kustomization depend on controllers
(98451cee), so a prune race could not delete namespaces that other Kustomizations had yet
to create. I dropped references to two repositories that no longer existed, e-nt-aras and
trading-code (cbb4081f). Earlier that month the same shape appeared: I deleted
git-credentials.yaml and left it listed in clusters/default/kustomization.yaml, which
stalled the clusters Kustomization (da595da8).
These rules came out of that week and live in the repository now:
- a Namespace belongs to exactly one Kustomization, and moving it means turning
pruneoff on the source first - a
resources:list cannot name a file that no longer exists - namespaces are created before the things that need them, by dependency rather than luck
October: the same lesson at smaller scale
On 2026-10-28 I moved Namespace/monitoring between Kustomizations. Flux garbage-collected
it, and the monitoring stack, Grafana and its CloudNativePG database went with it
(1ceca0d6). The prune: disabled annotation on the live object did not stop the garbage
collector.
October: Terraform leaves Tekton
For most of the year a hand-written PipelineRun applied Terraform. Each environment had
its own .tekton/*-tf-apply.yaml, fired by Pipelines-as-Code on a path change, carrying
credentials through Vault and running terraform apply inside a pod. It worked, and it
meant twelve copies of the same pipeline with twelve chances to drift.
On 2026-10-24 I installed the Flux OpenTofu controller (ad9d1af1) and declared a
Terraform custom resource per environment (24407ea0), starting plan-only. Three days later
I deleted what that replaced (16e50f2d): fourteen inert PipelineRuns and the eventlistener,
triggerbinding and triggertemplate chain behind them.
Drift correction now happens without me. An environment whose plan is clean gets
approvePlan: auto (802513c4, 8a90751b, 3ec26789) and Flux applies it every time git
changes. Environments with real decisions wait for a person. I keep state in S3, providers
lock in git (3bd39975), and the controller that owns the plan also runs the apply.
What the repository buys me
Nine months, over three thousand commits, two data wipes, one pivot and a pivot back. The layer diagram on the front page compresses that work into eight boxes, and the wrong turns stay in the commit log with their dates attached.
Concretely, the repository gives me three things: a date and a reason for every decision, the ability to replace a node without asking anyone, and a stack I can delete and recreate in an afternoon, which October proved by accident.
Documentation on this site follows the same order as the build, from the node upward, because I need the layer below to hold before the next one matters. The milestones from this post sit on the timeline, each with its date and commit.