k3s Cluster Incident: Ceph Latency, DiskPressure, and Stale CSI State

Three-node k3s HA cluster went crash-looping for two days — Ceph-backed boot disks, a full root disk, and stale storage-attachment state stacked on top of each other. Here’s the chain, the dead ends, and the fix.

Original work date

2026-08-15 to 2026-08-16

The problem

Restore a k3s HA cluster that had gone intermittently crash-looping. Workloads across the cluster were failing in a mix of CreateContainerError, ContainerStatusUnknown, and image-pull states, and repeated pod deletion wasn’t fixing anything.

The environment

What failed

A chain, not one fault:

The fix

Didn’t work, don’t repeat: deleting/recreating storage-bound pods, cutting VM memory, waiting for self-recovery, deleting VolumeAttachments without checking state first, trusting kubectl get nodes: Ready as proof of health.

Actually worked:

  1. Scale the workload to zero — don’t just delete the pod.
  2. Confirm no running pod still references the PVC.
  3. List VolumeAttachments by the underlying PV; wait for a normal detach.
  4. Only if an attachment is provably stale and stuck terminating, remove its finalizer.
  5. Recreate the workload after the old attachment is gone.
  6. For containerd: check each suspect snapshot directory against ctr metadata before touching it. Only delete ones confirmed orphaned (“snapshot not found”). Four directories on Navia qualified.
  7. One CephFS PVC (Obsidian) was genuinely unrecoverable — deleted intentionally, including finalizers, and its references pulled from the manifests that mounted it. A deliberate data call, not routine cleanup.
  8. Rebuilt the n8n image correctly for Alpine, pushed to the private registry. Reapplied DBOS secrets and fixed its ingress route (pointed at a service name that didn’t exist).

Structural fixes shipped:

Final state

Cluster stable since. Open follow-ups: protect Infisical (and other infra-critical workloads) from utilization-based eviction until fully validated, add ephemeral-storage requests/limits with DiskPressure alerting, add a read-only audit for containerd snapshots missing from metadata, validate Ceph CSI health end-to-end, and add a fourth Proxmox node so VMs/OSDs stop competing for the same three boxes.

What I learned

Useful commands / config

# check disk / containerd usage
df -h /
du -sh /var/lib/rancher/k3s/agent/containerd

# verify a snapshot is actually orphaned before deleting it
ctr -n k8s.io snapshots info <snapshot-id>
# "snapshot not found" == confirmed orphan, safe to remove

# inspect stuck volume attachments
kubectl get volumeattachment -o wide
kubectl describe volumeattachment <name>

# remove a finalizer on a confirmed-stale VolumeAttachment (last resort, after
# confirming the workload is scaled to zero and no pod references the PVC)
kubectl patch volumeattachment <name> -p '{"metadata":{"finalizers":null}}' --type=merge

# housekeeping (deployed as daily systemd units)
journalctl --vacuum-size=500M
k3s crictl rmi --prunepaste commands here

Secrets Are Not Configuration

.env files were fine until migrations, rotations, Kubernetes, OIDC, and app sprawl turned them into a liability.

Original work date

June

The problem

At small scale, .env files felt good enough. Then the environment grew. Apps moved hosts. Docker gave way to k3s. OIDC secrets regenerated. Databases stayed external. App configs needed to be restored, moved, and updated.

The environment

What failed

Suddenly, secrets were not just “configuration.” They were scattered operational risk.

The fix

Infisical became part of the platform foundation before the k3s app migration. Secrets could be stored centrally and delivered into Kubernetes through the operator instead of recreating the same .env sprawl in a new format.

Final state

What I learned

A secret copied everywhere is not really a secret.

Useful commands / config

paste commands here

Wireguard DNS Betrayal

WireGuard was connected, Technitium was alive, and .fndhome records existed. The problem was that wg-easy generated client profiles using the wg-easy host as DNS instead of Technitium.

Original work date

June

The problem

The VPN tunnel connected. The phone showed WireGuard as active. Technitium DNS was running. Internal records existed. From the server side, everything looked like it should work.

The environment

What failed

But the phone still was not resolving internal .fndhome names correctly. The actual problem was the DNS value in the WireGuard client profile. wg-easy was handing out the wg-easy host as DNS instead of the Technitium DNS server.

The fix

The fix was to change wg’s DNS setting to Technitium, regenerate or update the client profile, reload it on the phone, and verify Technitium live logs actually saw the phone’s queries.

Final state

What I learned

VPN connected does not mean DNS is right.

Useful commands / config

It's not DNS
There's no way it's DNS
It was DNS

-- a DNS haikumands here

Moving From Docker Hosts to Kubernetes Without Burning the House Down

The migration was not just putting apps into pods. It was separating application workloads from the core infrastructure those workloads depend on.

Original work date

June

The problem

The lab moved from scattered Docker hosts toward a k3s-based application platform. Kaveh and Thoma stopped being the center of the app world. Workloads moved into Kubernetes. The platform started to look more intentional.

The environment

What failed

But the important decision was what did not move into k3s.

The fix

DNS stayed in LXCs. PostgreSQL and MariaDB stayed external. Storage stayed on Zhongli. Backups stayed separate. Pangolin/Newt continued handling public access. Infisical handled secrets. Traefik handled internal .fndhome ingress. That separation kept the cluster from depending on itself for the things it needs to survive.

Final state

What I learned

Kubernetes was not the boss fight. Storage, secrets, databases, ingress, and rollback were the boss fight.

Useful commands / config

paste commands here

OIDC: Because Logging In Once Should Take Three Days

Single sign-on sounds simple until Authentik, Pangolin, app callback URLs, headers, secrets, internal routes, and public routes all need to agree on reality.

Original work date

June

The problem

The goal was simple: use SSO for self-hosted apps exposed through the homelab routing stack. The reality was not simple.

The environment

What failed

Some apps supported OIDC cleanly. Some needed proxy auth. Some cared deeply about callback URLs. Some needed internal SSL validation disabled. Some broke when provider secrets changed. Authentik provider edits could regenerate client secrets, which meant app environment variables and restarts had to stay in sync.

The fix

The actual work was not “turn on OIDC.” It was making the browser, app, proxy, identity provider, internal route, public route, and secrets all agree on the same story.

Final state

What I learned

OIDC works great once every tiny little liar in the chain agrees on reality.

Useful commands / config

paste commands here