This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a homelab infrastructure management repository that uses three main technologies:
- Ansible for configuration management of VMs and services
- Terraform for VM provisioning on Proxmox
- Kubernetes (k3s) for container orchestration with Flux CD
HOMELAB SETUP: Three Proxmox hosts, 78GB RAM total (31 + 15 + 31). pve002 is the constraint
at 15GB with ~2GB free — check it specifically, not just the cluster total.
- BE EXTREMELY CAREFUL with memory limits and resource requests
- ALWAYS CHECK current node memory allocation before increasing limits
- NEVER increase memory limits without checking cluster capacity first
- PiHole is CRITICAL - DNS outages affect entire home network
- Memory increases can trigger SystemOOM → MetalLB crashes → IP reassignments → DNS outages
PiHole provides DNS for entire home network - outages are unacceptable!
- NEVER drain nodes without first checking:
kubectl get pods -n pihole -o wide - If PiHole is on target node: Move it first with node selectors/affinity
- IP pinning configured: PiHole services pinned to 192.168.4.253
- During cluster changes: Expect brief IP reassignments during MetalLB restarts
- Emergency access: Use
kubectl port-forward -n pihole svc/pihole-web 8080:80
Check PiHole status:
kubectl get pods -n pihole
kubectl get svc -n pihole
nslookup google.com 192.168.4.253 # Test DNSBefore any resource changes:
kubectl describe nodes | grep -A10 "Allocated resources"
kubectl top nodesNEVER expose sensitive information in code or commits:
-
Domain names: NEVER hardcode domain names in files or commit messages
- Use Terraform variables for all domain references
- Keep actual values in
.tfvarsfiles (gitignored) - Use generic descriptions in commit messages
-
API tokens/keys: NEVER commit API tokens, credentials, or secrets
- Use SOPS encryption for Kubernetes secrets
- Use Terraform variables marked as
sensitive = true - Ensure
.tfvarsfiles are gitignored
-
IP addresses: Avoid exposing internal IPs in public commits
- Use variables or configuration files for network configuration
-
Account/Zone IDs: Use Terraform variables, never hardcode
Before committing:
- Review commit diff for any sensitive values
- Check commit message doesn't reference domains/IPs
- Verify
.tfvarsfiles are gitignored - Ensure secrets are encrypted with SOPS
# Run all playbooks
ansible-playbook site.yml
# Run specific playbook
ansible-playbook threekings.yml
ansible-playbook nyc.yml
# Run specific playbook with inventory
ansible-playbook -i inventory/threekings.yml threekings.yml
# Run k3s setup
ansible-playbook k3s-server.yml
ansible-playbook k3s-agent.yml# Initialize Terraform
cd terraform && terraform init
# Apply infrastructure changes
terraform apply
# Plan changes
terraform plan# Validate Kubernetes manifests
cd kubernetes && ./scripts/validate.sh
# Create secrets with SOPS
kubectl create secret generic --namespace $NAMESPACE --from-file values.yaml $SECRET_NAME --dry-run=client -o yaml
sops --encrypt pihole-secrets.yaml > pihole-secrets.sops.yaml
# Decrypt secrets
sops --decrypt <file>
kubectl get secret <name> -o jsonpath='{.data.values\.yaml}' | base64 -d
# Create config maps
kubectl create cm my-config --from-literal=key1=value1 --dry-run=client -o yaml- Proxmox: Hypervisor running on physical NUCs
- Terraform: Provisions VMs using cloud-init templates
- Cloud-init: Handles initial VM setup and Ansible bootstrap
- Ansible: Manages VM configuration and service deployment
- Roles: Organized by service type (docker-*, monitoring, networking)
- Inventories: Separated by location (threekings, nyc, centennial)
- k3s: Lightweight Kubernetes distribution. Bundled servicelb and traefik are
disabled (
roles/k3s-serverconfig); MetalLB and the Flux-managed Traefik are used instead. - Flux CD: GitOps continuous delivery
- MetalLB: Load balancer for external IPs. Pin an IP with the
metallb.universe.tf/loadBalancerIPsannotation (orspec.loadBalancerIP) —metallb.io/loadBalancerIPsis silently ignored and the service gets auto-assigned. - kube-vip: Control-plane VIP
192.168.4.5(control-plane-only DaemonSet, ARP + leader election). Server nodes join via this VIP (k3s_server_endpoint) and kubectl points at it, so no single server is a hard dependency. Servers stay on DHCP. - SOPS: Secrets management with GPG encryption
- Monitoring: Grafana, InfluxDB, Telegraf, Prometheus
- Networking: PiHole, ddclient, WireGuard
- Media: HTPC stack (*arr services)
- Document management: Paperless-ngx
site.yml: Main playbook that includes all servicesgroup_vars/: Host-specific variables organized by locationinventory/: Ansible inventory filesroles/: Service-specific Ansible roles
terraform/main.tf: VM definitions and configurationterraform/modules/cloud_init/: Cloud-init template moduleterraform/modules/cloud_init/files/: User data files per VM type
kubernetes/clusters/liberty/: Flux CD cluster configurationkubernetes/apps/: Application definitions with Helm releaseskubernetes/infra/: Infrastructure components (MetalLB, NFS)kubernetes/scripts/validate.sh: Validation script for manifests
- Terraform changes: Modify VM configurations in
terraform/main.tf - Ansible changes: Update roles or playbooks, test with
ansible-playbook - Kubernetes changes: Modify manifests, validate with
kubernetes/scripts/validate.sh - Secrets: Use SOPS for encryption, never commit plain secrets
- Cloud-init may not run on first boot in Proxmox
- Regenerate cloud-init image in Proxmox UI and reboot if needed
- Cloud-init logs:
cloud-init collect-logsthen examine the tarball
| Proxmox host | IP | k3s | Other VMs |
|---|---|---|---|
ryanrishi (NUC) |
192.168.4.200 | 1 server, 2 agents (one with iGPU) | molt, media (stopped) |
pve002 (M920q) |
192.168.4.202 | 1 server, 1 agent | — |
pve003 (NUC) |
192.168.4.203 | 1 server, 2 agents (one with iGPU) | — |
Exactly one k3s server per Proxmox host. Preserve this: it is what lets any single host reboot without losing etcd quorum.
k3s nodes are named k3s-{server,agent}-<6 random chars> — there are no ordinals, and every IP is
DHCP. Get current values from kubectl get nodes -o wide. media and molt are deliberately NOT
managed by Terraform.
Single provider, bpg/proxmox, named proxmox. Node name → IP comes from var.pve_nodes, which
the provider also uses for SSH (snippet uploads have no API equivalent, and the hostnames do not
resolve in DNS). SSH key path comes from PROXMOX_VE_SSH_PRIVATE_KEY in the gitignored
terraform/.env.
VMs are built directly from a pinned, checksummed Debian cloud image via disk.import_from (see
terraform/images.tf). There is no Proxmox template to maintain and no create-debian-template.sh.
Use the generic cloud image, not genericcloud — the -cloud kernel ships no DRM drivers, so
i915 can never bind and GPU passthrough silently fails.
The random suffix is stable in state, so a -replace on the VM brings it back under the same
name — and k3s writes node identity only at first registration, so the surviving node object keeps
its old IP and loses its --node-label flags.
terraform apply -replace='random_string.k3s_agent[N]' -target='module.k3s_agents'This cascades because name → cloud-init hostname → snippet content hash → user_data_file_id,
and that field is ForceNew. name alone is not.
kubectl drain evicts Longhorn's instance-manager, and without it there is no agent left on the
node to garbage-collect replicas — they strand at stopped forever and the node CR outlives
kubectl delete node.
kubectl patch nodes.longhorn.io <node> -n longhorn-system --type=merge \
-p '{"spec":{"allowScheduling":false,"evictionRequested":true}}'allowScheduling: false must be in the same patch or the webhook rejects it; kubectl cordon does
NOT set it. Wait until every affected volume is healthy with its full replica count running on
other nodes. Re-derive which volumes live on the node immediately beforehand — placement
moves constantly, and longhorn-single volumes have only one copy.
- Rebuilding a node:
kubectl delete nodefirst, then recreate. - Removing one permanently: destroy the VM FIRST, then
kubectl delete node. The VM stays alive during the ~90s destroy, and a live k3s server will re-register its own node object inside that window. That happened on 2026-07-28: a removed server came backNotReadywithEtcdIsVoter=Trueafter its VM was gone, leaving etcd at 4 members with one permanently dead — quorum met by exactly 3 live servers, i.e. zero fault tolerance, with nothing visibly broken.kubectl delete noderemoves the etcd member whether or not the VM exists, so there is no benefit to going first.
Re-check kubectl get nodes a minute after any server removal. Whether the object comes back
is a race against the kubelet heartbeat, so absence immediately after the delete proves nothing.
Bring the new server up and confirm EtcdIsVoter=True before removing the old one. 3 members and 4
members both tolerate one failure; 2 members tolerate none. There is no etcdctl installed on the
nodes — use the node condition instead:
kubectl get nodes -l node-role.kubernetes.io/etcd=true -o json \
| jq -r '.items[]|"\(.metadata.name) \([.status.conditions[]|select(.type=="EtcdIsVoter")|.status]|join(""))"'If the departing server holds the kube-vip lease (kubectl get lease -n kube-system plndr-cp-lock -o jsonpath='{.spec.holderIdentity}'), move it off before draining so the VIP fails over gracefully
instead of dying with the VM. The DaemonSet's label is app.kubernetes.io/name=kube-vip-ds — a
selector of name=kube-vip matches nothing and exits 0, so it looks like it worked while doing
nothing.
Deleting the pod alone does not move the lease, and neither does deleting the lease alongside it. A freshly-started kube-vip acquires immediately, while the incumbents sit on a slow retry — so the departing server's recreated pod wins the re-election within ~10s, every time. The delete must block until the pod is actually gone, and only then can the lease be deleted:
kubectl delete pod -n kube-system -l app.kubernetes.io/name=kube-vip-ds \
--field-selector spec.nodeName=<server> --wait=true --timeout=60s
kubectl delete lease -n kube-system plndr-cp-lockVerify the holder actually changed, and re-check a minute later — the recreated pod is still a candidate and the point is for it to lose. The API stays up throughout; the VIP moves without a readyz failure.
- SOPS encrypted secrets use GPG key pair
- Ansible vault not currently implemented
- Cloud-init templates contain sensitive data via Terraform variables
- Never commit
.tfvarsfiles or unencrypted secrets
Don't put "data" in comments - information that can become outdated causes confusion.
This includes:
- Specific values defined elsewhere (RAM, disk sizes, counts)
- Context-specific reasons for current state (why something is disabled, temporary workarounds)
Examples:
- Bad:
# Schedule on nodes with 4GB RAM and 80GB disk - Bad:
enabled: false # Disabled until storage and media stack are working - Good:
# Schedule on agent nodes which have more resources - Good:
enabled: false
The code should speak for itself. If someone later changes enabled: false to enabled: true, any comment explaining why it was disabled is now wrong and misleading.
--disable <addon> uninstalls the addon, and if it owns CRDs those get deleted
(taking all their CR objects with them). Disabling bundled traefik removed the core
traefik.io CRDs (IngressRoute/Middleware), which the Flux-managed Traefik relied on
— silently breaking all IngressRoute routing (e.g. the media *arr apps). The
Flux Traefik chart had only ever installed the hub.traefik.io CRDs because the core
ones already existed (from the bundled addon).
Fix / prevention: make the replacement own its CRDs. For the Flux Traefik
HelmRelease, set spec.install.crds and spec.upgrade.crds to CreateReplace, then
force a Helm upgrade (flux reconcile helmrelease traefik -n traefik --force) so Flux
(re)installs the chart's CRDs.
Changing a cluster-settings value used in a HelmRelease (e.g. SVC_TRAEFIK_IP for a
MetalLB pin) requires reconciling the HelmRelease in the same pass, not just the
kustomization — otherwise the ConfigMap updates but the rendered Service keeps the old
value until the next Helm apply, briefly moving the IP and breaking anything (like DNS)
that points at the intended one.
Philosophy: Debug via kubectl from outside the cluster, not by SSH'ing into nodes.
Minimal cloud-init packages: VMs are provisioned with only essential packages (nfs-common, qemu-guest-agent).
If you need to debug on a node, install packages temporarily:
# SSH to the node
ssh ryan@<node-ip>
# Install debugging tools as needed
sudo apt-get update && sudo apt-get install -y \
bind9-dnsutils \ # DNS debugging (dig, nslookup)
htop \ # Resource monitoring
jq \ # JSON parsing
lsof \ # Port/file descriptor debugging
netcat-openbsd \ # Network testing
nmap # Network scanningThese tools are intentionally NOT in cloud-init to encourage debugging from outside the cluster.
ha-manager crm-command node-maintenance does not work here and must not be used. It requires
HA-configured guests and shared storage; this lab has neither (no proxmox_virtual_environment_ha_*
resources, every disk on node-local local-lvm), and two agents use iGPU passthrough, which cannot
migrate at all. Nothing would move — the command is a no-op that reads like a safeguard.
Redundancy comes from the k3s layer instead: one k3s server per Proxmox host, so a single host can be down without losing etcd quorum. Only ever take down one host at a time. Evacuate Kubernetes, then stop the guests:
# 1. Gates: 8/8 nodes Ready, etcd 3/3 voters, all Longhorn volumes healthy, PiHole 3/3, DNS resolving.
# Re-derive which k3s nodes are on this host — names and IPs are DHCP and placement drifts.
kubectl get nodes -o wide -L topology.kubernetes.io/zone
# 2. If this host's server holds the kube-vip lease, move it first.
kubectl get lease -n kube-system plndr-cp-lock -o jsonpath='{.spec.holderIdentity}'
kubectl delete pod -n kube-system -l app.kubernetes.io/name=kube-vip-ds \
--field-selector spec.nodeName=<server>
# If the lease does not move (the DaemonSet can recreate the pod and reacquire), delete the lease
# to force re-election: kubectl delete lease -n kube-system plndr-cp-lock
# 3. Cordon every node on the host FIRST, so drained pods do not land back on it. Then drain.
kubectl cordon <server> <agent...>
kubectl drain <agent> --ignore-daemonsets --delete-emptydir-data --timeout=15m
# 4. Stop only guests that are actually running, so stopped VMs can never be in scope.
RUNNING=$(qm list | awk '$3=="running" {print $1}'); echo "$RUNNING"
for id in $RUNNING; do qm shutdown $id --timeout 180; doneExpect the agent drain to sit on the Longhorn instance-manager PDB until volumes detach. If it
never completes, the node likely holds the last replica of some volume — Longhorn's
node-drain-policy is block-if-contains-last-replica, which is protecting data, not
malfunctioning. The workload pods will already have moved; that is what matters.
Do not set evictionRequested for a reboot — that is for permanent removal and has stalled for
30+ minutes. Draining is not optional either: node-down-pod-deletion-policy is do-nothing, so
pods with Longhorn volumes hang until the node returns rather than rescheduling.
Symptom: /etc/pve/nodes/[nodename] directory access hangs, preventing SSL certificate access
Root Cause: Cluster filesystem communication errors, often after node restarts or network issues
Solution:
# Stop all PVE services
systemctl stop pve-cluster pve-ha-crm pve-ha-lrm pvedaemon pveproxy
# Force kill pmxcfs and unmount filesystem
pkill -9 pmxcfs
umount -l /etc/pve
# Restart cluster service cleanly
systemctl start pve-cluster
# Verify directory access works
timeout 5 ls -la /etc/pve/nodes/[nodename]/Symptom: pvecm updatecerts --silent hangs during pveproxy startup, preventing web interface access
Root Cause: Certificate update command hangs due to cluster communication issues
Solution: Create systemd override to skip the problematic pre-start command:
systemctl edit pveproxy
# Add these lines:
# [Service]
# ExecStartPre=
systemctl daemon-reload
systemctl start pveproxySymptom: Processes stuck in uninterruptible sleep (D state), survive SIGKILL Root Cause: Processes waiting for kernel I/O operations that will never complete Solution: Reboot required to clear kernel I/O wait states
# Check for D state processes
ps aux | grep -E " D "
# Only solution is reboot
systemctl rebootSymptom: Host becomes unreachable; k3s nodes go NotReady; NIC may not recover without physical reboot
Root Cause: Intel I219-V NIC driver bug triggered by high multicast traffic (mDNS/SSDP)
Hardware: all three hosts use the Intel I219-V. Historically ryanrishi (NUC) recovered on its
own via an adapter reset, while pve002 (M920q) hung fatally and needed a physical reboot.
Fix: Disable TSO/GSO to avoid stuck transmit path:
ethtool -K eno1 tso off gso offPersist by adding post-up ethtool -K eno1 tso off gso off to the iface eno1 stanza in
/etc/network/interfaces. Applied and persistent on all three hosts. It survives major
upgrades, but re-verify after any kernel or PVE version change — the offloads reverting is silent
until the host wedges:
ethtool -k eno1 | grep -E "^tcp-segmentation-offload|^generic-segmentation-offload" # both "off"Long-term fix: Replace onboard NICs with PCIe cards (Intel I350, X710, or Broadcom BCM5720)
Diagnostics:
# Check for hangs in current boot
journalctl -b | grep "Hardware Unit Hang"
# "Reset adapter" message = recoverable; no reset message = fatal, needs physical reboot# Check cluster status
pvecm status
pvecm nodes
# Check corosync communication
corosync-cmapctl | grep members
# Verify cluster filesystem access
ls -la /etc/pve/nodes/
timeout 5 ls -la /etc/pve/nodes/[nodename]/
# Check SSL certificates
openssl x509 -in /etc/pve/nodes/[nodename]/pve-ssl.pem -text -noout | grep "Subject:"
openssl rsa -in /etc/pve/nodes/[nodename]/pve-ssl.key -check