First installation :
flux bootstrap github \
--private=false \
--token-auth \
--owner=unshade \
--repository=homelab \
--branch=main \
--path=clusters/homelab \
--personalThen paste PAT with required scopes.
Trash flux :
flux uninstall --namespace=flux-system --keep-namespacePublic entry is a VPS (HAProxy, TCP passthrough + PROXY protocol) over a WireGuard tunnel to a
pod in-cluster, which relays to Envoy Gateway for TLS termination — replaces the old Cloudflare
Tunnel setup. See vps/README.md for the VPS side and
clusters/homelab/apps/wireguard/ for the cluster side. Adding a new hostname needs no changes
here — just point DNS at the VPS IP and add the HTTPRoute in-cluster.
Run on the Proxmox host nas (10.200.0.20, was 192.168.1.201 before the router migration -
see router/README.md) — the rw= clause is a colon-separated ACL of every
node IP allowed to mount the NFS export; add a new node's /32 to the list rather than widening
to a whole subnet. Kept the stale old-network entries below rather than pruning them - harmless,
not worth the risk of a typo on a live export ACL for zero functional benefit:
zfs set sharenfs="rw=@192.168.1.252/32:@192.168.1.206/32:@192.168.1.5/32:@10.200.0.52/32:@10.200.0.7/32:@10.200.0.8/32,no_root_squash,no_subtree_check" Main/data| Cluster name | talos-proxmox-cluster |
| Nodes | talos-cp1 (10.200.0.52, control-plane), w-1 (10.200.0.7 - temporary, see below) — both static |
| Interface | ens18 (both nodes) - talos-cp1 is also dual-homed on ens19 (10.200.0.52), ens18's old 192.168.1.252 kept as an inert rollback address, no longer routed |
| Gateway | 10.200.0.1 (was 192.168.1.254 before the router migration) |
| Talos version | v1.13.5 |
| Kubernetes version | v1.36.2 |
| CNI | Cilium (replaces flannel), kube-proxy-replacement mode, LoadBalancer IPs via L2 announcement (replaces the never-installed MetalLB) — see Networking: Cilium |
| Role | single control-plane (talos-cp1) by design — a 2-node etcd (the earlier setup) has zero real fault tolerance anyway, since quorum of 2 needs both members healthy. talos-cp1 has allowSchedulingOnControlPlanes: true so it still runs regular pods alongside w-1, not just etcd/control-plane |
| Data disk | sdb, 430GB, XFS, mounted at /var/mnt/data on each node (see Storage section) — longhorn-strict-local volumes are pinned to whichever node the pod using them lands on, so both nodes need their own copy of this disk/mount, not a shared one |
w-1's10.200.0.7is temporary -pve0(the Proxmox host it runs on) ended up claiming the originally-planned10.200.0.6by mistake during the migration and was left as-is; reconcile both to clean, final addresses eventually. Both nodes' static IPs should stay excluded from the router's DHCP pool (.100-.199) so they never get handed out to something else - seerouter/README.md's addressing table.
cluster.controlPlane.endpoint is https://talos-api.lan:6443 (a floating DNS record on the
Mikrotik - see router/README.md - rather than a literal IP, so it can survive control-plane
ever moving to a different physical node). Cilium's k8sServiceHost is 10.200.0.52. Both used
to be hardcoded to 192.168.1.252 and both were genuinely risky to change - changing either one
independently invalidates every service-account token cluster-wide, since Talos derives
--service-account-issuer/--api-audiences from cluster.controlPlane.endpoint by default. Both
are now explicitly pinned via cluster.apiServer.extraArgs in talos/patches/common.yaml
instead, permanently decoupled from whatever this endpoint says - see router/README.md's Phase
3 notes for the full incident writeup (twice) and the actual safe procedure.
Repo layout under talos/, secrets/SOPS handling, how to change the machine config, and the
full historical log of how the cluster got bootstrapped and grew from one node to two all live
in talos/README.md — that's the runbook to reach for when actually
operating the cluster day to day. This file stays focused on architecture/design (below) and
the facts that don't change often (above).
A Mikrotik router, managed with Terraform, sits behind the existing home network and hosts the
homelab on its own isolated 10.200.0.0/24 segment. See router/README.md
for the Terraform config, addressing, and the cutover history (both Talos nodes and the Proxmox
hosts are fully migrated onto it as of this writing).
The cluster originally ran Talos's default CNI (flannel) and kube-proxy. MetalLB was planned for
LoadBalancer IPs but never actually installed. All three were replaced with
Cilium: it's the CNI, it replaces kube-proxy (eBPF-based Service routing,
kubeProxyReplacement: true), and it hands out LoadBalancer IPs itself via L2 announcement
(ARP-based — there's no BGP router on this LAN, so BGP mode wasn't an option) instead of MetalLB.
Managed via Flux, same pattern as everything else in clusters/homelab/apps/:
repositories/helm/cilium.yaml—HelmRepositorypointing athttps://helm.cilium.io/.apps/cilium/release.yaml— theHelmRelease(installed intokube-system, chart1.19.6).apps/cilium-config/lb-pool.yaml— aCiliumLoadBalancerIPPool(10.200.0.90–10.200.0.99, was192.168.1.190–192.168.1.200before the router migration) andCiliumL2AnnouncementPolicy, the direct equivalent of MetalLB's IPAddressPool + L2Advertisement. This is its own FluxKustomizationwithdependsOn: [cilium], since its CRDs only exist once the Cilium HelmRelease has installed.
Key Helm values, and why each one is there (Talos needs some overrides other distros don't):
kubeProxyReplacement: true
k8sServiceHost: 10.200.0.52 # this node's static IP
k8sServicePort: 6443
cgroup:
autoMount:
enabled: false # Talos already mounts cgroupv2 itself
hostRoot: /sys/fs/cgroup
securityContext:
capabilities: # see "Problem 1" below — required on Talos
ciliumAgent: [CHOWN, KILL, NET_ADMIN, NET_RAW, IPC_LOCK, SYS_ADMIN, SYS_RESOURCE, DAC_OVERRIDE, FOWNER, SETGID, SETUID]
cleanCiliumState: [NET_ADMIN, SYS_ADMIN, SYS_RESOURCE]
operator:
replicas: 1 # chart default is 2; this is a one-node clusterk8sServiceHost/k8sServicePort are mandatory in kube-proxy-replacement mode: without kube-proxy
there's no ClusterIP-to-apiserver translation, so Cilium needs the real API endpoint directly.
On the Talos side (talos/patches/common.yaml), two fields turn off the defaults Cilium is
replacing:
cluster:
network:
cni:
name: none # don't deploy flannel
proxy:
disabled: true # don't deploy kube-proxyThis is inherently disruptive: for a moment there is no working CNI at all. Existing pods keep
their IPs, but the moment flannel's DaemonSet is gone, its VXLAN interface and iptables rules go
with it — pod-to-pod networking breaks until Cilium's datapath is up. kubectl/talosctl stay
reachable throughout (the control plane runs as static, host-network pods), so it's recoverable
even mid-outage, but nothing pod-network-based works until Cilium is healthy.
What actually happened, for reference — three separate problems stacked on top of each other:
-
Capabilities error. Cilium's
clean-cilium-stateinit container immediately crashed:unable to apply caps: can't apply capabilities: operation not permitted. Talos's default container capability bounding set is stricter than the Cilium chart assumes — the fix is the explicitsecurityContext.capabilitiesblock above, which Talos's own Cilium guide documents. Without it, Cilium simply cannot start on Talos, full stop. -
Flux locked itself out. While Cilium was crash-looping (no working CNI yet), Flux's own controllers —
source-controller,kustomize-controller,helm-controller— are ordinary pod-network pods, not host-network. They lost their route to the API server's ClusterIP along with everything else, so Flux couldn't reconcile the fix I'd already pushed to git. The cluster was stuck: no CNI, and the tool that would normally fix it couldn't reach anything either. Recovery was a one-time manualhelm upgrade --install cilium cilium/cilium ...run from outside the cluster (a laptop, talking straight to the node's real IP, bypassing in-cluster networking entirely) using the corrected values. That brought Cilium up, which restored pod networking, which let Flux's controllers reconnect and take back over on their own — Flux's next reconcile recognized the manually-installed release and adopted it with a normalhelm upgrade, no special handling needed. Takeaway: if a from-scratch CNI bootstrap ever needs to happen again, don't assume Flux can be the thing that installs the CNI it depends on to run — keep a manualhelm installescape hatch in mind. -
Stale pod sandboxes. Once Cilium was healthy, pods that had been running before the cutover (
flux-system,cert-manager,envoy-gateway-system) still showed0/1 Readywith liveness/readiness probes failing onno route to host. Their network sandboxes were created under flannel's old routing and never got migrated — they needed a fresh sandbox under Cilium. Fix:kubectl delete podon each (all are Deployment-managed, so they self-heal immediately with new, correctly-routed IPs). This is expected any time the CNI itself changes underneath already-running pods; it isn't Cilium-specific.
Verifying it's actually healthy:
kubectl get nodes # Ready
kubectl get pods -A # everything Running, no stale sandboxes
kubectl -n kube-system exec ds/cilium -- cilium-dbg status --brief # "OK"
kubectl get ciliumloadbalancerippools # IPs available
kubectl get ciliuml2announcementpolicies
flux get helmrelease -A # cilium: ReadyHow to know the schema/values for a chart like this in general: helm show values cilium/cilium --version 1.19.6 dumps every configurable value with inline comments — that's the
authoritative source, not guessing from blog posts. For the CRDs Cilium itself installs
(CiliumLoadBalancerIPPool, etc.), kubectl explain ciliumloadbalancerippool.spec works the same
way kubectl explain does for any other CRD, once the CRD is installed.
UserVolumeConfig is a Talos machine config document, not a Kubernetes resource — it lives
in talos/patches/common.yaml (shared by both nodes, since both have the same disk layout;
HostnameConfig is the one document that's genuinely per-node, hand-written in each node's own
talos/patches/talos-cp*.yaml), and it's how Talos itself (not Kubernetes)
declaratively owns a disk: which physical disk to use, what filesystem to put on it, where to
mount it on the node. There is no kubectl get uservolumeconfig — the object doesn't exist in
the Kubernetes API at all, only in Talos's own config/resource system on the node:
talosctl -n 10.200.0.52 get volumestatus u-data # provisioning result: partition, size, phase
talosctl -n 10.200.0.52 get mountstatus # confirms it's mounted at /var/mnt/dataapiVersion: v1alpha1
kind: UserVolumeConfig
name: data # mount path becomes /var/mnt/<name>
provisioning:
diskSelector:
match: '!system_disk' # "the disk Talos isn't installed on" — sdb
minSize: 400GB
filesystem:
type: xfsThis is layer 1 of 2. Talos formatting and mounting /var/mnt/data just makes 430GB of disk
available on the node — it does nothing for Kubernetes by itself. Layer 2 is a Kubernetes
StorageClass/provisioner that actually turns that mounted directory into PersistentVolumes
pods can claim — this is Longhorn (clusters/homelab/apps/longhorn/),
using its strict-local data-locality mode rather than the plain single-node
local-path-provisioner originally considered here.
strict-local forces exactly one replica, pinned to the same node as the pod using it — no
cross-node replication attempted, which fits a single-node cluster fine (multi-replica would just
mean permanently-degraded volumes trying to place replicas on nodes that don't exist). Unlike
local-path-provisioner, Longhorn still provides snapshots, backups, and online volume expansion —
worth having for apps that manage their own redundancy at the application level (e.g. a Postgres
with its own backup strategy) and just need a real block-storage feature set underneath. The
longhorn-strict-local StorageClass (numberOfReplicas: "1", dataLocality: "strict-local") is
the cluster's default. When more nodes are added later, a second StorageClass with a higher
replica count can just be added alongside it — nothing about this setup blocks that, replica
count is a per-StorageClass (and per-volume) parameter, not a cluster-wide one.
Longhorn's engine needs iSCSI tooling Talos doesn't ship by default — this needs two Talos system
extensions (siderolabs/iscsi-tools, siderolabs/util-linux-tools) baked into the boot image via
a talosctl upgrade --image factory.talos.dev/installer/<schematic>:<version>, plus a
machine.kubelet.extraMounts bind-mount so hostPath-based pods can actually see
/var/mnt/data (paths on the Talos host aren't automatically visible inside the kubelet's own
mount namespace). See Longhorn's Talos support
doc.
TODO — cold storage: a separate NFS-backed StorageClass, backed by a ZFS dataset exported
from the Proxmox host itself rather than this local disk. Trades a bit of performance for the
ability to browse/manage/snapshot the data directly from Proxmox, which a local block device
fundamentally can't offer — sdb//var/mnt/data stays reserved for regular fast PVCs.