Kubernetes Infrastructure¶
Complete operational reference for the heezy Kubernetes cluster.
Last verified against the live cluster: 2026-08-22.
Cluster Topology¶
Nodes: 5 (MicroK8s HA)
| Node | IP | OS | k8s Version |
|---|---|---|---|
| nebula-1 | 192.168.1.15 | Ubuntu 24.04.3 LTS | v1.32.9 |
| nebula-2 | 192.168.1.16 | Ubuntu 24.04.3 LTS | v1.32.9 |
| nebula-3 | 192.168.1.17 | Ubuntu 24.04.3 LTS | v1.32.9 |
| nebula-4 | 192.168.1.18 | Ubuntu 24.04.3 LTS | v1.32.9 |
| nebula-5 | 192.168.1.19 | Ubuntu 24.04.3 LTS | v1.32.9 |
Control Plane: MicroK8s HA, backed by dqlite (not etcd). Three nodes are datastore voters and
two are standby; MicroK8s picks them itself and promotes a standby when a voter leaves. Which node
holds which role changes over time, so never encode it anywhere. Every node runs an apiserver and can
serve kubectl.
CNI: Calico
Node roles: kubectl get nodes shows <none> for every node. That is normal for MicroK8s — it
does not apply the node-role.kubernetes.io/* labels, and it does not mean the nodes are workers only.
Namespaces¶
| Namespace | Purpose |
|---|---|
heezy |
Most homelab workloads: media, finance, mailbot, SWAG, Pi-hole, promtail |
gitea |
Gitea, its Postgres, and the act-runner |
openbao |
OpenBao secrets manager |
external-secrets |
External Secrets operator |
secrets |
ExternalSecret targets and pull secrets |
longhorn-system |
Longhorn distributed storage |
metallb-system |
MetalLB load balancer |
ingress |
MicroK8s' bundled nginx ingress controller DaemonSet. Not used for heezy services — SWAG in heezy is the real ingress |
gpu-operator-resources |
NVIDIA GPU operator and node feature discovery |
kube-system |
Kubernetes core services |
default |
Two legacy NFS browse PVCs, nothing else |
Storage¶
Longhorn¶
Distributed block storage, 3 replicas per volume by default.
- Replication: 3 replicas spread across nodes
- Use case: All application configs and databases
- Provisioner:
longhornStorageClass - Access mode: RWO (a few volumes use RWX)
- Resilience: Auto-rebuild of a lost replica onto a healthy node
Health check:
Expected: all volumes state: attached, robustness: healthy. A detached/unknown row is a
volume with no pod attached, not necessarily a fault.
There is no Longhorn backup target
backup-target is empty, so nothing is shipped off-cluster by Longhorn. The only recurring job
is snapshot-cleanup (Sundays 05:00), which deletes system-generated snapshots left behind by
replica rebuilds and purges the frozen layers. Snapshots live on the same disks as the data and
are not backups. See Backups for what actually protects this data.
NFS (CSI Driver)¶
RWX volumes from the TrueNAS box, via csi-nfs-node on every node.
- NAS: 192.168.1.200, dataset
Arr1/SMB, mounted on hosts as/nfs - Use case: Media, downloads, ingest directories
- Access mode: RWX (ReadWriteMany)
- Provisioner:
nfs.csi.k8s.io(StorageClassnfs-media), plus a set of hand-written static PVs inbase/nfs-media-static-pvs.yaml
Volumes in use: nfs-media-{movies,tv,anime,music,roms,audiobooks}, nfs-downloads,
nfs-slskd-downloads, nfs-receipts, nfs-heezy-ingest, nfs-statements-ingest.
Find PVCs no pod is using:
kubectl get pvc -A -o json | jq -r '.items[] | "\(.metadata.namespace) \(.metadata.name)"' | while read ns n; do
used=$(kubectl get pods -n "$ns" -o json | jq --arg n "$n" '[.items[].spec.volumes[]?.persistentVolumeClaim.claimName] | index($n)')
[ "$used" = "null" ] && echo "unused: $ns/$n"
done
MetalLB VIPs¶
Pool default-addresspool is 192.168.1.25–192.168.1.40.
| VIP | Service | Ports | Purpose |
|---|---|---|---|
| 192.168.1.25 | heezy/swag | 80, 443 | Main ingress (HTTPS termination) |
| 192.168.1.27 | heezy/pihole-lb | 53, 80 | DNS + admin UI |
| 192.168.1.28 | heezy/plex | 32400 | Plex direct access (HomeKit, clients) |
| 192.168.1.30 | heezy/swag-plex | 80, 443 | Plex via reverse proxy (remote access) |
| 192.168.1.31 | gitea/gitea-lb | 3000 | Git + Gitea Actions |
| 192.168.1.32 | openbao/openbao-lb | 8200 | OpenBao, follows the active pod |
| 192.168.1.33 | heezy/uptime-kuma | 3001 | Direct app access; SWAG does not proxy its Socket.IO API |
| 192.168.1.34 | heezy/slskd-lb | 5030, 50300 | Soulseek daemon behind the gluetun VPN sidecar |
Half the pool is inside the DHCP scope and unreserved
The FortiGate hands out 192.168.1.10–192.168.1.199 on internal7, which covers the whole MetalLB
pool. Only .25, .27, .28 and .30 have dummy-MAC DHCP reservations holding them. .31, .32, .33 and
.34 do not, so a new DHCP client could be handed a live VIP. Adding a reservation is a
terraform-heezy change.
Pod Placement & Node Affinity¶
Current state (verified 2026-08-22): no heezy workload sets nodeSelector or nodeAffinity. The
only pods with a selector are the GPU operator's node-feature-discovery workers, which use
gpu-operator: enabled because that is hardware-bound.
Leaving pods unpinned buys resilience, balanced placement, and simpler troubleshooting.
HARD RULE: Never add nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution for a specific
hostname unless it is hardware-required (GPU, a specific device). If you pin a workload, say why
in a comment on the manifest.
Check current affinity:
kubectl get pods -A -o custom-columns=NS:.metadata.namespace,POD:.metadata.name,NODESEL:.spec.nodeSelector --no-headers | grep -v '<none>'
Resilience Posture¶
| Workload | Replicas | Notes |
|---|---|---|
| Most apps | 1 | Homelab — a brief restart is acceptable |
| act-runner | 3 | One per node, so a node reboot does not stall CI |
| pihole | 1 | DNS. Two replicas plus anti-affinity is backlogged |
| SWAG | 1 | Ingress; single point of failure, accepted |
QoS: the long-running deployments now set requests, so they land Burstable or Guaranteed. What is still BestEffort is short-lived CronJob pods (mailbot, finance-sync, statement-scanner, budget-alerts, ecr-credentials-refresh, openbao-unseal) and the bundled nginx-ingress DaemonSet. The 2026-06 finding that named sabnzbd, radarr and sonarr no longer applies.
kubectl get pods -A -o custom-columns=NS:.metadata.namespace,POD:.metadata.name,QOS:.status.qosClass --no-headers | grep BestEffort
Known Issues¶
Services using hostNetwork¶
Plex — intentional and permanent. Plex must see a real LAN IP (192.168.1.x) on its interface so
it registers a reachable address with plex.tv. Without it, clients receive the pod's 10.1.x.x
cluster IP, fall back to the Plex relay, and show as Remote/Indirect with throttled bandwidth and
forced transcoding. See Plex for full details.
Overseerr — hostNetwork: true with no matching justification. It is not pinned to a node, so the
host port follows the pod around the cluster, and nothing depends on that address anyway: Overseerr
has a normal ClusterIP service on 5055 and SWAG proxies overseerr.trentnielsen.me to
overseerr.heezy.svc.cluster.local:5055. Dropping hostNetwork is a safe cleanup, backlogged.
Leftover debug pods¶
curl-rc, curl-rc2 and mailbot-probe4 are still Running in the heezy namespace. They are
one-off troubleshooting pods that were never deleted and are not in any manifest.
Disaster Recovery¶
dqlite Quorum¶
MicroK8s HA runs dqlite with 3 voters out of the 5 nodes; quorum is 2 of 3.
- One voter down: cluster keeps working, a standby is promoted
- Two voters down at once: the API goes read-only/unavailable until a voter returns, because promotion needs quorum to commit
- Losing two standby nodes is harmless
This is weaker than "survive any 2 node failures" — the answer depends on which two.
Longhorn Volumes¶
3-replica volumes tolerate losing 2 of the 3 replicas' nodes. There is no Longhorn backup target configured, so recovery from a full-cluster loss means restoring from whatever Backups covers, not from Longhorn.
Cluster Loss Recovery¶
- Rebuild 5 Ubuntu 24.04 VMs on Proxmox
- Install MicroK8s v1.32.9 on each and
microk8s add-node/microk8s jointhem - Enable the addons:
dns,ha-cluster,gpu,dashboard - Clone heezy-k8s
kubectl apply -k base/for namespaces, storage classes, MetalLB config and static NFS PVskubectl apply -k apps/<service>/per service, or letauto-deploy.yamldo it- Restore data per Backup & Restore
Approx. recovery time: hours, not minutes. The 45-minute figure only covers steps 1–3.
kubectl Access¶
Everything reaches the cluster through the mcp-tools container, which bind-mounts ~/.kube. The
kubeconfig context is microk8s and points at https://192.168.1.15:16443.
That endpoint is a single node. If nebula-1 is down, repoint the kubeconfig server at another node's
:16443 — any of them serves the API.
Common Commands¶
Node Status¶
# List all nodes with details
kubectl get nodes -o wide
# Check node resource usage
kubectl top nodes
# Describe a specific node (events, conditions)
kubectl describe node nebula-1
Pod Placement & Status¶
# List all pods across all namespaces with node assignment
kubectl get pods -A -o wide
# Check pod resource usage
kubectl top pods -A
# Get pod logs
kubectl logs -n heezy <pod-name>
# Port-forward to a pod (for local debugging)
kubectl port-forward -n heezy svc/<service-name> 8080:80
Force Pod Restart¶
Useful when a pod is stuck or needs cache refresh:
# Restart all replicas of a deployment
kubectl rollout restart deployment/<name> -n heezy
# Wait for rollout to complete
kubectl rollout status deployment/<name> -n heezy
# Example: restart heezy-finance
kubectl rollout restart deployment/heezy-finance -n heezy
kubectl rollout status deployment/heezy-finance -n heezy
Longhorn Volume Health¶
# List all volumes with health status
kubectl get volumes.longhorn.io -n longhorn-system
# Describe a specific volume (replicas, state)
kubectl describe volume <name> -n longhorn-system
# Check the recurring jobs and the backup target
kubectl get recurringjobs.longhorn.io -n longhorn-system
kubectl get settings.longhorn.io -n longhorn-system backup-target -o jsonpath='{.value}'
Gitea DB Sync (Stale Run Status)¶
If a Gitea workflow shows stale status (appears "cancelled" but steps completed), manually sync the database:
kubectl exec -n gitea gitea-postgres-0 -- psql -U gitea -d gitea -c \
"UPDATE action_run_job SET status=3 WHERE status IN (1,2) AND task_id IN (SELECT id FROM action_task WHERE stopped>0);"
This updates all incomplete jobs with completed tasks to status 3 (completed).
Check Service IPs & Endpoints¶
# List services with MetalLB VIPs
kubectl get svc -A -o wide | grep LoadBalancer
# Check endpoint IPs for a service
kubectl get endpoints -n heezy <service-name>
Scale Deployment¶
# Set replica count
kubectl scale deployment/<name> -n heezy --replicas=2
# Or use kubectl edit for full control
kubectl edit deployment/<name> -n heezy
Monitoring & Observability¶
There is no Prometheus Operator and no ServiceMonitor CRDs in this cluster. Prometheus runs as a
Docker Compose service on big-boi (192.168.1.21) and scrapes static targets rendered by the
lgtm Ansible role from inventory/hosts.yml: node-exporter on every Linux host, promtail on :9080,
kubelet and cAdvisor on :10255, kube-state-metrics, the nvidia GPU exporter on big-boi, and the
snmp-exporter for the FortiGate and the Cisco switch.
Adding a scrape target is an ansible-heezy change to roles/lgtm/templates/prometheus.yml.j2, not a
k8s manifest.
Loki ingests logs from promtail: a systemd service on all 12 Linux hosts, plus the promtail
DaemonSet in heezy for container logs.
Grafana: https://grafana.heezy.info (direct: http://192.168.1.21:3000).
See Monitoring and Capacity Review.
Best Practices¶
- Always use kubeconfig: never hardcode API endpoints in a manifest
- Use namespaces: separate concerns (heezy workloads vs. gitea vs. system)
- Set resource requests: on anything long-running, so it is not BestEffort
- Use Longhorn for stateful: all databases and config on Longhorn volumes, media on NFS
- No direct
kubectl apply: push to heezy-k8s and let CI apply it (see CI/CD) - Document affinity reasons: if pinning a pod, explain why in a comment
- Do not treat Longhorn snapshots as backups: they sit on the same disks, and no backup target is configured. See Backups