A decade of Kubernetes at home

The Ship of Theseus
Cluster

Every node, every network plugin, every ingress controller and every storage layer has been replaced. It's still the same cluster.

whoami

Andrew Stuart

  • Platform & infrastructure engineer: Kubernetes, Go, cloud, CI/CD
  • 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

  1. The story: 10 years in 10 minutes
  2. How it works today
  3. What broke and what I learned
  4. Live demo
  5. 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
  • Dual-stack IPv6, Pi-hole, Loki, Jaeger, OpenTelemetry, Velero, Zalando postgres-operator
47889c42021-11-24IPV6 omg
49c8c112021-11-24no ipv6
13ce3b12021-11-25Update IP ranges for dual stack etc.
Why it matters: I replaced the hardware without rebuilding the cluster. The API objects carried forward, and only storage was rebuilt from scratch.
201617181920212223242526
2022HARDENING

Supply chain and guardrails

  • Harbor becomes the CRI-O pull-through mirror for docker.io and quay.io on every node
  • OPA Gatekeeper for admission policy, plus a custom CA-injector webhook so CI runners trust the internal PKI
  • Tigera operator takes over Calico, and the cluster moves to 1.23 the same day
  • kubelet serving certs from my own PKI, the first try at Argo CD, and ucml.dev for the business
0a62a122022-06-23Tigera and other major !@#$ updates
Why it matters: running your own mirror and policy protects you from upstream outages. It also creates a bootstrap loop (see the war stories).
201617181920212223242526
2023AUTOMATION

Renovate, and real users

  • Renovate (Jan 2023) opens PRs for chart and image bumps, and commit volume jumps almost 5×
  • Public, multi-user services: Lemmy (during the Reddit API exodus), Mastodon, Matrix
  • Velero + CSI snapshots for backups; Loki in simple-scalable mode; Pyroscope profiling; Argo CD in HA
  • git-crypt for secrets in the repo; Istio and Ory experiments; Atuin, Plausible, Postfix relay
d9172952023-01-20Add renovate.json
81007322023-08-08Velero fix, maybe. Just stop snapshotting pls
Why it matters: automating upgrades turned them from quarterly dread into a daily non-event.
201617181920212223242526
2024PRODUCTION

The family depends on it now

  • Immich for family photos, Frigate NVR on the GPU node (Multus gives it a second NIC for cameras), Home Assistant
  • Terraform for DNS zones and the MikroTik router, so infrastructure as code reaches past Kubernetes
  • Tried Traefik in May 2024 and went back to nginx five days later
  • The busiest day in the repo's history: 70 commits, most of them a Mastodon rebuild
50a86862024-02-16Try trusted again now that I know the 403s are nginx missing ipv6
b4397592024-05-13Back to nginx
Why it matters: once family photos and the front-door camera live here, it's production. Treat it that way.
201617181920212223242526
2025SECURITY & SCALE

Patch days and a seven-version upgrade

  • CVE-2025-1974 "IngressNightmare": patched chart and webhook disabled the day after disclosure
  • Rook v1.11 → v1.18 and Ceph Reef → Squid with the data online
  • GitLab and Keycloak moved onto postgres-operator; Keycloak HA; Vaultwarden; a Samba NAS on CephFS; a Tailscale subnet router
  • Added CLAUDE.md: AI-assisted ops with written runbooks, and read-only checks before any change
9d89d8e2025-03-25No webhook for CVE-2025-1974
91009682025-10-21Upgrade Rook-Ceph: v1.11.9→v1.18.4 operator, Ceph v18→v19
Why it matters: the focus moved from adding features to paying down operational debt: CVE response, big upgrades, recovery runbooks.
201617181920212223242526
2026MODERNIZE

Paying down the debt

  • Gateway API: about 50 routes moved from ingress-nginx to Traefik, split into public and internal Gateways with LAN-only by default
  • kubelet stops being compiled from source; it now installs from release binaries
  • 2021 CA renewed, now valid to 2036; PostgreSQL 17; Plausible v3
  • A second site on k3s that applies the lessons to a clean, small build, with a DDNS updater written in Go
02a816c2026-04-17feat(ingress): migrate ingress-nginx to Traefik Gateway API
330f2582026-07-28chore(ansible): install released k8s binaries, renew CA, bump to v1.35.5
Why it matters: commit messages became conventional commits with real explanations, because future-me is a teammate too.
201617181920212223242526
Lineage

Every layer has been swapped at least once

02 / HOW IT WORKS

The architecture today

Bare metal, done the hard way, on purpose.

Request path
EDGE Visitors IPv4 + IPv6 Cloudflare (proxied) stuart.fun · edge TLS · WAF Route 53 · BIND · Pi-hole astuart.co · ucml.dev · LAN split-horizon HOME NETWORK MikroTik router RouterOS · Terraform-managed · :443 fwd MetalLB VIPs L2 · 22 LoadBalancer IPs · v4 + v6 KUBERNETES v1.35 · 4 NODES · ARCH LINUX · CRI-O · CALICO Traefik · Gateway API public-gateway · internal-gateway (lan-only default) cert-manager LE DNS-01 · Vault PKI external-dns ×4 cloudflare · route53 ×2 · bind Family photos · NVR · HA Community sites · games Business ucml.dev · GitLab Keycloak SSO OIDC for apps + kubectl Observability prom · loki · otel · jaeger Rook-Ceph 36 OSDs · HDD / SSD / NVMe classes · RBD + CephFS + S3 Velero → Backblaze B2 · PG backups → RGW Control plane, the hard way 4 converged nodes · etcd on systemd · static pods ansible · cfssl PKI (CA to 2036) · Harbor pull-through
Control plane, the hard way

Why not kubeadm or k3s?

  • kubeadm didn't exist when this cluster was born
  • Owning every flag and cert meant understanding every flag and cert
  • Upgrades are an ansible run: new release binaries and templated static-pod manifests, one node at a time
  • kubectl logs in through Keycloak OIDC; the apiserver trusts auth.stuart.fun
  • The trade-off: I am the managed service. The CA expiry in 2026 was mine to catch.

The second site uses k3s. I pick the tool to fit the job.

# ansible/roles/kubehost — per control-plane host
- name: kubehost | ssl | etcd
  shell: |
    cfssl gencert -ca stuart-2021-ca.crt \
      -ca-key <(gpg --decrypt stuart-2021-ca.key.gpg) \
      -profile peer -cn "etc{{etcnum}}.astuart.co" \
      -hostname kubernetes.default.svc,10.254.0.1,... \
      client.json | cfssljson -bare etc{{etcnum}}
- name: kubehost | manifest | apiserver
  template:
    src: kube-apiserver.yml.j2
    dest: /etc/kubernetes/manifests/kube-apiserver.yml
- name: kubehost | systemd | start/restart etcd
  systemd: { name: etcd, state: restarted }
What it runs

Real workloads for real people

Family

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

  1. Upgrade continuously, in place. Small steps, health-gated, automated by Renovate.
  2. Git is the only source of truth. Controllers will enforce whatever git says, including the gaps.
  3. Secure by default. LAN-only unless declared public; SSO everywhere; a PKI I understand.
  1. Observe the internals, not just the front door. Alert on cert expiry, backup age and sync status, not just uptime.
  2. Know your bootstrap graph. Keep a runbook for the day everything is off.
  3. 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

QR code for homelab.stuart.fun
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.2 2016-05-12 service default/kubernetes 2016-07-02 configmap default/vault # Vault-on-etcd era 2016-07-12 configmap kube-system/blackbox-cfg # first Prometheus probes 2016-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 alpha 2017-03-07 service kube-system/kube-lego-nginx # before cert-manager 2017-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.

served by