Running Kubernetes since early 2016 (v1.1/1.2), at home and at work
Founder of Microcumulus; its product runs on this cluster at ucml.dev
Tinkerer: home automation, radios, NVRs, far too many disks
Today
The story: 10 years in 10 minutes
How it works today
What broke and what I learned
Live demo
Q&A
Exhibit A
andrew@home — kubectl
$ kubectl get namespace default -o jsonpath='{.metadata.creationTimestamp}'
2016-05-12T01:20:53Z$ kubectl version | tail -1
Server Version: v1.35.5
This etcd dataset has been upgraded in place for 3,787 days. It's older than:
kubeadmHelm 2StatefulSetsCRDsRBACapps/v1cert-managerGateway API
The paradox
If you replace every plank, is it the same ship?
Hardware: three Dell servers replaced the old machines in 2021, and every Node object was re-registered then, five years after the cluster's first API objects.
Network: Canal → Calico → dual-stack Calico under the Tigera operator
Ingress: nginx → ingress-nginx → Traefik + Gateway API
Storage: gluster (for one day) → NFS → Rook → OpenEBS → Rook-Ceph again. The Ceph monitors are now named k, ae, af: that's the 32nd mon.
Version: about 33 Kubernetes minors, and kubelet compiled from source until 2026
“etcd says yes.”
Identity lives in the data, not the machines.
Right now
What the cluster looks like today
Age
10.4years
since 2016-05-12
Kubernetes
v1.35.5
4/4 ready
Running pods
310
91 namespaces
Workloads
255
59 HTTP routes
CPU in use
17.9/ 140 cores
Memory in use
231/ 500 GiB
Ceph used
88/ 156 TiB raw
Ceph OSDs up
36
112 PVs · 76.6 TiB claimed
dell01
48cores 125GiB
5.0y old
dell02
40cores 188GiB
5.0y old
dell03
40cores 125GiB
5.0y old
htpc
12cores 62GiB
1 GPU · 5.1y old
Live from /api/live: Kubernetes API plus Prometheus, cached 10s. About 275 W at the wall, 295 Mb/s on the wire.
01 / THE STORY
Ten years in ten minutes
1,195 commits by one person. Here's what the git log remembers.
Commits per year
The shape tells the story: build it, let it run, rebuild the foundation, then automate everything so upkeep stops being a chore.
Carbon dating
The cluster remembers its own upgrades
I never kept an upgrade log, but I didn't need one. Each release creates built-in objects (APIService registrations, bootstrap RBAC roles, FlowSchemas) the first time it starts, and their creationTimestamp records when it happened.
kubectl get apiservices -o custom-columns=CREATED:.metadata.creationTimestamp,NAME:.metadata.name \
--sort-by=.metadata.creationTimestamp
2016GENESIS
Hand-written YAML and big ambitions
First commit Feb 21, 2016: redis, postgres and GitLab as ReplicationControllers
Mar 2016: "Update to use deployments", back when Deployments were extensions/v1beta1
Jenkins, Mattermost, gluster volumes, Vault on etcd, dex for SSO, and Prometheus + Grafana (July 2016)
Workloads pinned to one box by nodeName: htpc, and a Minecraft server with persistence
f4d0da62016-03-23Update to use deployments
Why it matters: I learned Kubernetes by running my own tools on it. Source control, CI, chat and monitoring came before any "real" app.
201617181920212223242526
2017GROWING UP
Security and networking get real
RBAC in Jan 2017 while it was v1alpha1. That RoleBinding still exists, now served as v1.
Upgrade to 1.6 and Canal CNI for NetworkPolicy, on the same day
kube-lego for automatic Let's Encrypt, before cert-manager existed; Helm 2 and Tiller
Private registry, Nexus, SonarQube, Alertmanager, EFK logging, a Cassandra StatefulSet, Fission functions, an Ethereum node
b7e3a372017-01-08Add nfs provisioner. Totes broken.
31df9552017-01-20Add RBAC
Why it matters: RBAC, CNI and Helm all arrived within about 8 weeks. Adopting early meant paying for it later in migrations, which is why this talk exists.
201617181920212223242526
2018→ 2020
The quiet years
Life happened. 68 commits in three years, and only 9 in 2020. The cluster just kept running.
One weekend in Oct 2018: Canal → Calico, plus MetalLB ("Metal :metal:")
cert-manager replaces kube-lego; CoreDNS, external-dns, OPA; the first Rook
May 2020: "ALL THE UPDATES", most likely a jump to 1.18. Commit messages shrink to "Stuff".
7d4bc5b2018-10-23Metal :metal:
c1b43b12018-12-20Rooooookk
57981402019-09-26Yolo
Why it matters: running unattended for years is the real test. The good kind of boring still piles up debt, and 2021 paid it off.
201617181920212223242526
2021THE REBUILD
New hardware, same cluster
Three Dell servers join in Sep 2021 and the old machines are drained away. The etcd data moves with them.
A new CA with cfssl and a GPG-encrypted key; ansible provisions etcd and the control plane
Helm 3 charts; Rook-Ceph rebuilt (after an OpenEBS detour); Keycloak SSO plus a home-grown oauth2-proxy controller written in Go
Immich (photos) · Frigate NVR with GPU detection · Home Assistant · ESPHome · zigbee2mqtt · Vaultwarden · Plex and friends · Samba NAS
Community & friends
WordPress sites for family and friends' businesses · about 15 domains · Plausible analytics · Terraria & Factorio servers · formerly Lemmy, Mastodon and Matrix
Business & dev
Microcumulus (ucml.dev) · two GitLab instances + CI runners · Harbor · Argo CD · 8 Postgres clusters · Garage S3 · a Go oauth2-proxy controller
Namespaces
91
Deployments + StatefulSets
255
Routes (Ingress + HTTPRoute)
59
03 / WAR STORIES
What broke, and what it taught me
Ten years means everything has failed at least once.
War story · 2026
The migration that quietly opened the doors
What happened
ingress-nginx → Traefik Gateway API, about 50 routes. Every route was ported, but nginx's global RFC1918 allowlist lived in a ConfigMap, not on any route. For about an hour, ~30 internal apps (Prometheus, the k8s dashboard, Pi-hole, the *arr stack) were reachable from the internet.
Fix, same day
A lan-only IPAllowList middleware attached to every non-public HTTPRoute, and externalTrafficPolicy: Local so the allowlist sees real client IPs instead of SNAT'd node IPs. I verified it by narrowing the allowlist and checking for a 403 from the LAN. Public became opt-in.
Migrate behavior, not config. Defaults that live in global controller config never show up in a per-route inventory, so diff what the system does.
War story · found while writing this talk
A rotation isn't done until everyone reloads
What happened
The cluster CA expired on 2026-05-16 and was re-issued with the same key. The API server was happy, but every long-running process had cached the old CA cert. For four months, quietly: Prometheus service discovery froze, the Ceph CSI provisioners and two MetalLB speakers lost the API, CRI-O on one node couldn't pull from the internal registry, and Argo CD went dark.
How I found it and fixed it
The deck's own metrics never showed up in Prometheus. Its logs said x509: certificate has expired. A sweep of every pod started before the renewal found 14 more. Rolling restarts, one component at a time, checking the VIP and HEALTH_OK between steps. I deliberately left Argo CD alone: it has auto-sync and prune enabled, and months of drift need a diff review before it reconnects.
Trust changes need a rollout plan: restart everything that holds the old trust, and alert on x509 errors. Silence isn't health, because a controller that can't watch looks exactly like one with nothing to do.
War story · 2026
The outage nobody noticed (until I did)
What happened
stuart.fun moved to Cloudflare nameservers, but the ACME issuer only knew Route 53. Eight certs quietly expired. The sites stayed up because Cloudflare's edge doesn't verify the origin cert in ssl=full mode.
Fix
Added a Cloudflare DNS-01 solver selected by dnsZones. Selector-matched solvers win, so no Certificate needed editing. Stale TXT records from a cert-manager bug were cleaned up by hand.
A green dashboard can hide red internals. Alert on cert expiry at the origin, not on whether the site is up. Turn on ssl=strict so the edge fails loudly.
War story · 2026
Hand-edits are future outages
What happened
Five Gateway listeners had been added by hand in-cluster and never committed. A routine helm apply dropped them. Routes went NoMatchingParent, and external-dns (policy=sync) deleted their DNS records within a minute.
Fix
Moved those hosts to self-contained Ingresses: the TLS block drives the cert and the rules drive DNS. No central list to drift from. Git became the only source of truth again.
Automation amplifies drift. Anything not in git will eventually be "corrected" by a controller doing exactly what it was told.
War story · recovery
The chicken-and-egg power outage
After a power cut, nothing came back. Every node pulled images through Harbor, which ran on the cluster. DNS came from Pi-hole, which also ran on the cluster.
The fix was a runbook, committed to the repo as if-breakers-broke.md:
if-breakers-broke.md
1. Start the servers
1. Ensure bind is running on htpc
1. Point resolv.conf at bind until
[pihole] is running
1. Remove harbor from registries.conf
on every node, restart crio
1. Get harbor running again
1. Wait for everything to come back up
Map your bootstrap dependencies. Anything the cluster needs to start (DNS, images, secrets, identity) needs a path that doesn't go through the cluster. Vault storing its data in the cluster's own etcd is next on that list.
War story · 2025
Upgrade debt compounds
How it got bad
In May 2025 I bumped the Rook CRDs to v1.16 but left the operator on v1.11.9. By October the cluster was seven minors behind, still on Ceph Reef, and carrying tens of TiB of family data with no second cluster to fail over to.
Paying it down
HEALTH_OK first. Then CRDs → common → CSI operator → operator, and Rook rolled mons → mgrs → MDS/RGW → OSDs one at a time while the data stayed online. I pinned timestamped Ceph images, fixed the toolbox last, and wrote the procedure into the repo.
Deferred upgrades don't stay small. Keep CRDs and operators in lockstep and take small hops, because the one big hop is the scary one.
War story · lightning round
The commit log doesn't lie
9d89d8eNo webhook for CVE-2025-1974
IngressNightmare: patched chart plus the published mitigation (webhook off) the morning after disclosure. Know the mitigation, not just the fix.
6c0e735Something deleted the argo CRDs and I'm PISSED
Deleting a CRD cascades to every custom resource. Now Argo owns its CRDs explicitly, and ownership is pinned.
50a8686…the 403s are nginx missing ipv6
An allow-list that only knew IPv4 met a dual-stack world. Test both families.
853538eCalico you f#$@!r
Swapping a CNI under running pods: plan a maintenance window, then plan another.
710c536OMFG
One of 70 commits on the busiest day, mostly a Mastodon rebuild. Debugging charts by pushing to git is slow, so now I use helm template and diff locally first.
a4f8855update vault ClusterIssuer caBundle with re-issued CA
The 2021 CA expired in May 2026 and Vault-issued renewals stalled. It was re-issued with the same key so existing leaf certs kept verifying. Valid to 2036.
Honesty slide
What I found while writing this talk
Preparing this talk was an audit. Here's what it turned up, and what's next:
Backups: silently failing
Every Velero run to Backblaze B2 fails on an x-amz-tagging header, probably from an unpinned AWS plugin image. A backup job nobody watches doesn't protect you. Next: pin the plugin and alert on the last successful backup.
GitOps: dark since May
Argo CD's 17 apps have shown Unknown since May: a stale CA and an expired Git token. Next: diff all 17 apps before reconnecting, then alert on sync status.
Fossils
CoreDNS is still 1.3.1 from 2019, cert-manager 1.11 is from 2023, and Bitnami's 2025 catalog change broke four images. Next: let Renovate cover the control-plane add-ons too.
Fixed while writing
11 components stuck on the old CA were restarted (Argo CD deliberately left). A crashed Ceph mgr module was failed over, and Ceph is back to HEALTH_OK. Prometheus discovers pods again.
What I'd bring to your team
Principles, paid for in outages
Upgrade continuously, in place. Small steps, health-gated, automated by Renovate.
Git is the only source of truth. Controllers will enforce whatever git says, including the gaps.
Secure by default. LAN-only unless declared public; SSO everywhere; a PKI I understand.
Observe the internals, not just the front door. Alert on cert expiry, backup age and sync status, not just uptime.
Know your bootstrap graph. Keep a runbook for the day everything is off.
Write it down for future-you. Commit messages and runbooks in the repo, readable by people and by AI pair-programmers.
04 / LIVE DEMO
Let's poke it
① Archaeology
The oldest objects still in etcd
② Kill this deck
Delete the pod serving these slides
③ Ship it
A new public HTTPS service in about a minute
Watch the badge in the bottom-right corner →
Still running
Questions?
3,787
days, one etcd
310
pods, right now
homelab.stuart.fun
Appendix · archaeology
The oldest objects still in etcd
kubectl get … -A --sort-by=.metadata.creationTimestamp
2016-05-12 namespace default # bootstrap, k8s 1.22016-05-12 service default/kubernetes
2016-07-02 configmap default/vault # Vault-on-etcd era2016-07-12 configmap kube-system/blackbox-cfg # first Prometheus probes2016-08-24 service kube-system/kube-gen-certs
2016-09-13 job default/open-greyhound-nginx
2016-12-23 service kube-system/psql
2017-01-21 rolebinding default/read-only # RBAC while it was alpha2017-03-07 service kube-system/kube-lego-nginx # before cert-manager2017-03-08 replicaset default/mattermost-343439506
Written as extensions/v1beta1 and rbac/v1alpha1, and still served today as apps/v1 and rbac/v1 after about 33 minor upgrades. The apiserver converts on read.