Rock 5T Talos Kubernetes Cluster

:rocket: Rock 5T Talos Linux Kubernetes Dev Cluster

After running a production Kubernetes cluster on Hetzner Cloud for a while, I wanted a local twin — same GitOps, same stack, same APIs — but sitting on my desk. Something I could break, rewire, reflash, and rebuild without touching the cloud bill.

The result is this little beast: three Radxa Rock 5T boards, a Ubiquiti Flex 2.5G PoE with a single PoE++ cable and a properly grown‑up Kubernetes setup (Talos, Cilium BGP, Longhorn, Gateway API, Flux).

:thinking: Why Rock 5T — and why is this more than “just another SBC cluster”?

Let me be honest: We see a lot of Pi clusters, most of them are cute but slow, plugged into a USB‑C octopus, running k3s on SD/eMMC with low I/O and a single GbE NIC for CNI data plane and management plane. In my opinion this ends up compromising the stack to fit the hardware.

This build doesn’t have those problems. Here’s why the Rock 5T specifically changes the equation:

Details

:brain: 24 GB LPDDR5 RAM per node

The Raspberry Pi 5 tops out at 16 GB. Most other SBCs in the same price bracket offer 4–8 GB. The Rock 5T ships in the last spring deals came up with 24 GB LPDDR5 — that’s 1,5× the best Pi 5 you can buy. Across three nodes that’s 72 GB of cluster RAM total.

What does that mean in practice? You can run a genuinely full stack per node, in my case:

  • Cilium + Hubble agent

  • Cilium Gateway API

  • Longhorn engine + replica

  • Cert-Manager,

  • CloudNative-PG PostgreSQL cluster

  • Step-CA internal PKI

  • A dozen real applications

…and still have headroom. On an 8 GB node you’re constantly tuning memory limits and watching nodes go MemoryPressure. Here you mostly just deploy things.

:globe_with_meridians: TWO native 2.5 GbE NICs

Almost every SBC cluster you’ve seen has one network interface. Raspberry Pi 5 without HATs has one GbE. Most Rock 4/5 variants have one.

The Rock 5T has two independent, native 2.5 GbE NICs (both RTL8125BG on PCIe). This is a big deal for Kubernetes because it lets physically separate your data planes.

In this cluster:

  • NIC 1 carries the home LAN, Kubernetes API traffic, Gateway API/ingress, BGP sessions and LoadBalancer VIPs.

  • NIC 2 carries exclusively pod-to-pod traffic — isolated VLAN, no default route, the kernel literally cannot send pod packets out the wrong NIC.

You get the full bandwidth of both NICs simultaneously. With three nodes that’s up to 7.5 Gb/s of aggregate pod-to-pod capacity that never contends with external traffic. On a single-NIC SBC cluster everything fights for the same wire.

:floppy_disk: Real PCIe NVMe

SD card / eMMC Kubernetes clusters are notorious in the homelab world. Those storages have terrible random IOPS (Longhorn will eat them alive), they wear out, and they’re slow enough that etcd becomes a bottleneck.

The Rock 5T has a real M.2 2280 slot running PCIe 3.0. I’m running a Crucial P510 1 TB in each node. That’s real NVMe performance: random 4K reads that Longhorn, etcd and databases actually care about. The difference vs. eMMC in a distributed storage context is night and day.

:high_voltage: RK3588: A76 cores that genuinely perform

Not all ARM cores are equal. The older SBC ecosystem was mostly Cortex-A53/A55 — efficient, but weak single-threaded performance. The RK3588 uses 4× Cortex-A76 (up to 2.4 GHz) big cores + 4× Cortex-A55 little cores. For me the A76 generation was a significant generational jump for Kubernetes workloads.

:electric_plug: PoE+ that actually works

Most SBC needs a HAT for PoE. The Radxa 25W PoE+ Module 5514 is a different story — it’s a proper 25W IEEE 802.3at module that can be soldered cleanly onto the board and reliably delivers the power budget the Rock 5T needs under load.

Combined with a Ubiquiti 10G PoE+++ Adapter (90W) injector feeding a Ubiquiti Flex 2.5G PoE, the entire cluster runs off one cable from my router. No wall warts, no USB-C bricks, no power strip gymnastics. One cable to rule them all. :sweat_smile:

:bullseye: Talos Linux

Talos Linux has an official Rock 5T overlay in ghcr.io/siderolabs/sbc-rockchip, maintained alongside Talos releases. It means U-Boot is already compiled and tested, the kernel config is tuned and upgrades work through the standard talosctl upgrade path. First-class SBC support in the Talos ecosystem is still rare — the Rock 5T has it. :heart:


The comparison in one table

Raspberry Pi 5 cluster Typical SBC Rock 5T (this build)
RAM per node 4–16 GB 8–16 GB 24 GB
NVMe USB-bridged or none Often none PCIe 3.0 M.2 2280
NICs 1× GbE 1× GbE 2× 2.5 GbE (native)
CPU big cores 4× A76 (Pi 5) A55 only 4× A76 + 4× A55
PoE HAT quality Flakey / underpowered Good 25W (5514), solid

The Rock 5T is the first SBC I’ve used where I stopped making compromises to fit the hardware and just… ran the same stack I run in the cloud.

:brick: Bill of Materials

Component Qty notes
Radxa Rock 5T (RK3588, 24 GB) 3
Radxa 25W PoE+ Module 5514 3 Per-node PoE+ power
Crucial P510 1 TB NVMe (PCIe 3.0) 3 Talos system disk + Longhorn replicated storage
Ubiquiti Flex 2.5G PoE 1 8× 2.5 GbE PoE, 1× 10G SFP+, PoE-in
Ubiquiti UACC 10G PoE+++ Adapter (90W) 1 Powers switch + entire downstream cluster
Ubiquiti Dream Router 1 BGP peer (AS 64512) ↔ Cilium (AS 64513)
patch cables 7 1× PoE+++ uplink + 3× PoE++ node-to-switch + 3x node-to-switch

:high_voltage: Power budget

Item Idle Peak
3× Rock 5T + NVMe (incl. PoE+ overhead) ~20 W ~45 W
Flex 2.5G PoE (system) ~7 W ~9 W
Total at the wall ~27 W ~54 W

Comfortably inside the PoE availability of 76 W with PoE+++ input


:world_map: The Plan

There’s one catch with the Rock 5T: the BootROM boot order is SPI → eMMC → SD → USB — it cannot boot directly from NVMe. So the build looks like this:

  1. Pre-flash NVMe with the Talos image on your workstation.

  2. Temporarily boot Armbian from SD to gain access to the SPI NOR flash (/dev/mtdblock0).

  3. Flash U-Boot into SPI, then remove the SD card.

  4. On next boot: SPI U-Boot → NVMe → Talos. :tada:

  5. talosctl apply + bootstrap → Kubernetes is up.

  6. Cilium + BGP + Gateway API + Longhorn + Flux → it’s a real cluster. :rocket:

Below is the full sequence. Everything should be reproducible.

Reproducible plan

:hammer_and_wrench: Phase 1 — Bake a Talos image for the Rock 5T

Stock Talos doesn’t include everything we need. We need kernel modules for Longhorn, the Realtek 2.5 GbE firmware, and LLDP so UniFi can see the nodes in its topology view.

Open factory.talos.dev, pick Talos v1.12.8, “Single Board Computers”, “Radxa Rock 5T”, and add these extensions:

# rock5t-schematic.yaml
customization:
  systemExtensions:
    officialExtensions:
      - siderolabs/iscsi-tools         # Longhorn v1 iSCSI initiator
      - siderolabs/util-linux-tools    # Longhorn helpers
      - siderolabs/realtek-firmware    # RTL8125BG 2.5G NICs
      - siderolabs/lldpd               # nodes show up in UniFi topology
overlay:
  name: rock5t
  image: ghcr.io/siderolabs/sbc-rockchip

Download metal-arm64.raw.xz. Note your schematic ID — you’ll plug it into the Talos install.image later.


:hammer_and_wrench: Phase 2 — Extract U-Boot for SPI

The SPI NOR on the Rock 5T was shipped empty from the factory. We need a U-Boot that knows how to chain-load Talos from NVMe. The correct binary is bundled inside the sbc-rockchip overlay container.

crane --platform=linux/arm64 export ghcr.io/siderolabs/sbc-rockchip:v0.2.0 \
  | tar x --wildcards '*/rock5t/u-boot-rockchip-spi.bin'

You should now have a ~1.9 MB u-boot-rockchip-spi.bin. Keep it — you’ll copy it to each node in Phase 4.


:hammer_and_wrench: Phase 3 — Pre-flash the NVMes

It is much faster to flash three drives on your workstation with a USB→NVMe adapter than to do it in-place on the boards.

  1. Pop each Crucial P510 into a USB→NVMe enclosure.
  2. Flash the Talos image via balenaEtcher
  3. Install each NVMe into its Rock 5T board.

:hammer_and_wrench: Phase 4 — Flash SPI from Armbian (one-time, per node)

Talos is intentionally minimal — it does not expose /dev/mtd* and cannot flash the SPI NOR from within itself. Armbian’s vendor kernel does expose it, so we use it as a one-time stepping stone.

  1. Download Armbian Debian Trixie Minimal (vendor kernel) for Rock 5T and flash it to a microSD card.

  2. Insert the SD card into the first node (the pre-flashed NVMe is already installed — it will be ignored during this boot since SD wins the boot order). Power on, SSH in as root / 1234.

  3. Verify MTD is available:

    ls /dev/mtd*    # expect /dev/mtdblock0
    
  4. From your workstation, copy the SPI U-Boot over:

    scp u-boot-rockchip-spi.bin root@<armbian-ip>:/tmp/
    
  5. Flash and verify on the board:

    dd if=/tmp/u-boot-rockchip-spi.bin of=/dev/mtdblock0 bs=4096
    sync
    md5sum /tmp/u-boot-rockchip-spi.bin
    dd if=/dev/mtdblock0 bs=1 count=$(stat -c%s /tmp/u-boot-rockchip-spi.bin) | md5sum
    # MD5s must match
    
  6. Note both NIC MAC addresses (ip link show) — you’ll need them to pin interfaces deterministically in Talos.

  7. Power off. Remove the SD card. Repeat for nodes 2 and 3.

Future boots: SPI U-Boot → NVMe → Talos. The SD card is never needed again.


:hammer_and_wrench: Phase 5 — Network design

Each Rock 5T has two 2.5 GbE NICs and I use both, with completely separated roles:

NIC VLAN Subnet Purpose
enP4p65s0 (NIC 1) 10 — management 10.10.1.0/24 Home LAN, Kube API, Gateway API, BGP sessions, VIPs
enP3p49s0 (NIC 2) 11 — internal 10.10.2.0/24 Pod-to-pod traffic only — isolated
Talos VIP 10.10.1.100 Shared control-plane endpoint

NIC separation is enforced three ways — belt, braces and suspenders:

  1. Kubelet nodeIP.validSubnets: ["10.10.2.0/24"] → forces Cilium’s autoDirectNodeRoutes to install pod-CIDR routes via 10.10.2.x. East-west pod traffic exits NIC 2.
  2. No gateway, no default route on NIC 2. The kernel physically cannot route external traffic via the cluster NIC. This is enforced at the network layer, not just by policy.
  3. UniFi VLAN 11 (dev-cluster-internal) has Isolate Network: true and Allow Internet Access: false. Even if something misconfigured in software tried to send external traffic out NIC 2, the switch/router drops it before it gets anywhere. This layer is completely independent of the OS config — it holds even if you wipe and reinstall a node.

The result: you get full 2.5 Gb/s between pods and a separate 2.5 Gb/s for ingress, Gateway API, BGP and the Kube API, simultaneously, on the same hardware.

UniFi network + switch port setup

Before plugging in the nodes, create both networks in UniFi → Networks and assign the switch ports.

Network 1 — dev-cluster-management

Setting Value
VLAN ID 10
IPv4 Gateway 10.10.1.1/24
DHCP DHCP Server (range 10.10.1.6 – 10.10.1.254)
Isolate Network :cross_mark:
Allow Internet Access :white_check_mark:

Set DHCP reservations for each node’s NIC 1 MAC address here — BGP sessions from the Dream Router side target fixed IPs, so reservations prevent sessions dropping on DHCP renewal.

Network 2 — dev-cluster-internal

Setting Value
VLAN ID 11
IPv4 Gateway 10.10.2.1/24
DHCP None — Talos assigns static IPs
Isolate Network :white_check_mark:
Allow Internet Access :cross_mark:

Nodes configure their own static IPs on this NIC via the Talos machine config — no DHCP needed or wanted.

Switch port profile — per node, per NIC

Each Rock 5T has two cables going into the Flex 2.5G PoE. Configure each port in UniFi → Devices → Switch → Ports:

Port Native VLAN Tagged VLANs Auto PoE
NIC 1 (enP4p65s0) dev-cluster-management (10) Block All :white_check_mark: (powers the board via PoE+ module)
NIC 2 (enP3p49s0) dev-cluster-internal (11) Block All :cross_mark:

:safety_pin: Both ports use Uplink mode, not Edge. “Block All” on tagged VLAN management means no VLAN trunk — each port carries exactly one untagged network. NIC 1 and NIC 2 are physically on different VLANs at the switch level, which reinforces the isolation even before Talos/Cilium enforce it in software.


:hammer_and_wrench: Phase 6 — Talos config

Machine config is split into small patches by concern so it stays readable and reviewable. Here are the important ones:

install.yaml — disk + factory image

machine:
  install:
    disk: /dev/nvme0n1
    image: factory.talos.dev/installer/<your-schematic-id>:<version>

cluster.yaml — disable Flannel + kube-proxy, allow scheduling on all CP nodes

cluster:
  allowSchedulingOnControlPlanes: true
  network:
    cni:
      name: none        # Cilium takes over
    podSubnets:
      - 10.244.0.0/16
    serviceSubnets:
      - 10.96.0.0/12
  proxy:
    disabled: true      # Cilium handles kube-proxy replacement              

kubelet.yaml — pin nodeIP to the cluster NIC

machine:
  kubelet:
    nodeIP:
      validSubnets:
        - "10.10.2.0/24"

This single line is what makes Cilium’s direct node routes go out NIC 2 and not NIC 1. It’s the cornerstone of the NIC separation.

longhorn.yaml — pre-load kernel modules

machine:
  kernel:
    modules:
      - name: iscsi_tcp       # Longhorn v1 iSCSI initiator
      - name: dm_crypt        # Longhorn volume encryption
      - name: nvme_tcp        # Longhorn v2 NVMe-oF TCP (pre-loaded, not active)
      - name: nvme_fabrics    # Longhorn v2 NVMe-oF base (pre-loaded, not active)
  sysctls:
    # vm.nr_hugepages: "1024"   # SPDK: 1024 × 2MiB = 2GiB per node
    #                           # Uncomment only when v2DataEngine: true in Longhorn HelmRelease

ccm.yaml — Cloud Controller Manager & Bootstrap CRDs (optional)

cluster:
  externalCloudProvider:
    enabled: true
    manifests:
      - https://raw.githubusercontent.com/siderolabs/talos-cloud-controller-manager/v1.12.0/docs/deploy/cloud-controller-manager-daemonset.yml
      - https://github.com/prometheus-operator/prometheus-operator/releases/download/v0.90.1/stripped-down-crds.yaml
      - https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.1/standard-install.yaml
machine:
  features:
    kubernetesTalosAPIAccess:
      enabled: true
      allowedRoles:
        - os:reader
        - os:etcd:backup
      allowedKubernetesNamespaces:
        - kube-system

I my case the Talos CCM handles node lifecycle in the Kubernetes API (node taints, addresses). Prometheus Operator CRDs and Gateway API CRDs are bootstrapped here — before Flux runs — so that Cilium, cert-manager and kube-prometheus-stack can reference them immediately on first reconcile without ordering issues. Talos API access is scoped to kube-system for the CCM and etcd backup operator.

nodes/node-1.yaml — MAC-pinned interfaces + VIP

machine:
  network:
    hostname: "dev-cluster-node-1"
    interfaces:
      - deviceSelector:
          hardwareAddr: "00:48:54:xx:xx:xx"   # enP4p65s0 – home network
        dhcp: true
        vip:
          ip: 10.10.1.100
      - deviceSelector:
          hardwareAddr: "00:48:54:xx:xx:xx"   # enP3p49s0 – in-cluster
        addresses:
          - 10.10.2.1/24
        # No routes/gateway – intentional: isolates pod-to-pod traffic to this subnet

:safety_pin: Why hardwareAddr and not interface: eth0? On the Rock 5T, kernel interface names can renumber. MAC-based binding is the way to ensure your network config is deterministic.

Generate, apply, bootstrap

talosctl gen config dev-cluster https://10.10.1.100:6443 \
  --kubernetes-version v1.33.10 \
  --install-disk /dev/nvme0n1

# Apply common patches + node-specific patch to each node
talosctl apply-config --insecure --nodes 10.10.1.101 \
  --file controlplane.yaml \
  --config-patch @install.yaml \
  --config-patch @cluster.yaml \
  --config-patch @kubelet.yaml \
  --config-patch @longhorn.yaml \
  --config-patch @ccm.yaml \
  --config-patch @nodes/node-1.yaml

# Repeat for 10.10.1.102 / node-2 and 10.10.1.103 / node-3

# Bootstrap on node 1 only — once
talosctl -n 10.10.1.101 --talosconfig ./talosconfig bootstrap

# Grab kubeconfig
talosctl -n 10.10.1.101 --talosconfig ./talosconfig kubeconfig ~/.kube/config

kubectl get nodes
# NAME                   STATUS   ROLES           AGE   VERSION
# dev-cluster-node-1     Ready    control-plane   2m    v1.33.12
# dev-cluster-node-2     Ready    control-plane   2m    v1.33.12
# dev-cluster-node-3     Ready    control-plane   2m    v1.33.12

:safety_pin: Why Kubernetes v1.33.10 specifically?
My dev cluster intentionally runs the same Kubernetes version as the production Hetzner cluster — not the Talos default (which would be v1.35.2 for Talos v1.12.8).

:hammer_and_wrench: Phase 7 — Cilium with kube-proxy replacement + BGP + Gateway API

Why kube-proxy replacement? eBPF in the kernel is faster, lower overhead, and unlocks BGP and Gateway API as first-class citizens with no additional components.

Why BGP instead of L2 ARP for LoadBalancer IPs? Because all three nodes advertise the same IP simultaneously, and the Ubiquiti Dream Router installs three equal-cost routes. Incoming traffic is distributed 3-way across nodes — true ECMP (Equal Cost Multiple Path). No active/standby, no MAC flapping, no “whoever wins the ARP race gets all the traffic”. See the Cilium BGP Control Plane docs for full configuration reference.

My Cilium Helm values (managed via Flux HelmRelease):

apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: cilium
  namespace: kube-system
spec:
  interval: 1h
  chart:
    spec:
      chart: cilium
      version: "1.18.7"
      sourceRef:
        kind: HelmRepository
        name: cilium
        namespace: flux-system
      interval: 1h
  releaseName: cilium
  install:
    crds: CreateReplace
  upgrade:
    crds: CreateReplace
  values:
    kubeProxyReplacement: true
    k8sServiceHost: "10.10.1.100"
    k8sServicePort: 6443
    kubeProxyReplacementHealthzBindAddr: "0.0.0.0:10256"
    # native routing + kube-proxy replacement → skip conntrack iptables rules
    installNoConntrackIptablesRules: true

    socketLB:
      hostNamespaceOnly: true  # Talos: restrict socket LB to host netns

    # IPAM / routing
    ipam:
      mode: kubernetes

    k8s:
      requireIPv4PodCIDR: true

    routingMode: native
    ipv4NativeRoutingCIDR: "10.244.0.0/16"
    autoDirectNodeRoutes: true
    policyCIDRMatchMode: nodes

    # BPF
    bpf:
      masquerade: true
      datapathMode: veth
      hostLegacyRouting: false

    # Encryption
    encryption:
      enabled: false

    # Network devices
    devices:
      - enP4p65s0   # home network NIC
      - enP3p49s0   # in-cluster NIC

    # DNS proxy
    dnsProxy:
      enableTransparentMode: true

    # Egress gateway
    egressGateway:
      enabled: false

    # Load balancer
    loadBalancer:
      acceleration: disabled

    # BGP Control Plane – replaces L2 announcements
    bgpControlPlane:
      enabled: true

    # Gateway API
    gatewayAPI:
      enabled: true
      enableProxyProtocol: false    # no proxy protocol – BGP L3 LB
      enableAppProtocol: true
      enableAlpn: true
      externalTrafficPolicy: Cluster  # Cluster = all 3 nodes advertise VIP → true ECMP
      gatewayClass:
        create: "true"

    # Hubble observability
    hubble:
      enabled: true
      relay:
        enabled: true
      ui:
        enabled: true

    # Prometheus metrics
    prometheus:
      enabled: true
      serviceMonitor:
        enabled: false

    # Talos-specific: cgroup & capabilities
    cgroup:
      autoMount:
        enabled: false  # Talos manages cgroup mounts
      hostRoot: /sys/fs/cgroup

    securityContext:
      capabilities:
        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
    operator:
      replicas: 2
      nodeSelector:
        node-role.kubernetes.io/control-plane: ""
      podDisruptionBudget:
        enabled: true
        minAvailable: ~   # null → switches PDB to maxUnavailable mode
        maxUnavailable: 1
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: cilium-operator
          matchLabelKeys:
            - pod-template-hash
      prometheus:
        enabled: true
        serviceMonitor:
          enabled: false

BGP peering with the Ubiquiti Dream Router

# CiliumBGPClusterConfig — all three nodes peer with the Ubiquiti Dream Router
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPClusterConfig
metadata:
  name: cilium-bgp
spec:
  nodeSelector:
    matchLabels: {}  # all nodes
  bgpInstances:
    - name: cluster
      localASN: 64513
      peers:
        - name: udr7
          peerASN: 64512
          peerAddress: "192.168.0.1"  # Dream Router LAN IP
          peerConfigRef:
            name: cilium-peer-udr7
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPAdvertisement
metadata:
  name: bgp-lb-advertisement
  labels:
    advertise: bgp-lb   # matched by bgp-peer-config families[].advertisements
spec:
  advertisements:
    - advertisementType: Service
      service:
        addresses:
          - LoadBalancerIP
      selector:
        matchLabels: {}   # advertise all LoadBalancer services
# CiliumBGPPeerConfig — hold 9s / keepalive 3s → ~9s failover on node loss
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPPeerConfig
metadata:
  name: cilium-peer-udr7
spec:
  gracefulRestart:
    enabled: true
    restartTimeSeconds: 120
  timers:
    holdTimeSeconds: 9
    keepAliveTimeSeconds: 3
  transport:
    peerPort: 179
  families:
    - afi: ipv4
      safi: unicast
      advertisements:
        matchLabels:
          advertise: bgp-lb
# CiliumLoadBalancerIPPool — the IP range BGP will advertise
apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
  name: dev-pool
spec:
  blocks:
    - start: "10.10.1.200"
      stop:  "10.10.1.249"

Configuring BGP on the Ubiquiti Dream Router

UniFi manages FRR via a config file upload — no SSH required.

  1. Go to UniFi Network → Settings → Routing
  2. Click Add BGP and fill in:
    • Name: Kubernetes DEV
    • Device: <Dream Router>
    • Configuration File: upload the frr.conf below as unify-frr.conf
  3. Click ApplyConfiguring BGP on the Ubiquiti Dream Router
frr version 8.1
frr defaults traditional
hostname UDR7
!
! ============================================================
! BGP – Kubernetes dev cluster (AS 64513)
! Nodes peer via enP4p65s0 (DHCP from 10.10.1.0/24)
! ⚠ Set DHCP reservations in UniFi for each node MAC before
!   enabling – BGP sessions drop if node IPs change
! ============================================================
router bgp 64512
 bgp router-id 10.10.1.1
 bgp log-neighbor-changes
 !
 ! ECMP: accept equal-cost paths from same remote AS
 bgp bestpath as-path multipath-relax
 maximum-paths 3
 !
 ! Peer group – shared config for all 3 nodes
 neighbor K8S-NODES peer-group
 neighbor K8S-NODES remote-as 64513
 neighbor K8S-NODES description dev-cluster
 neighbor K8S-NODES timers 3 9
 neighbor K8S-NODES timers connect 5
 !
 ! ⚠ Replace with actual DHCP-assigned node IPs
 ! Check UniFi → Clients, or: talosctl get addresses -n <node>
 neighbor 10.10.1.101 peer-group K8S-NODES
 neighbor 10.10.1.102 peer-group K8S-NODES
 neighbor 10.10.1.103 peer-group K8S-NODES
 !
 address-family ipv4 unicast
  ! Activate all peers in the group
  neighbor K8S-NODES activate
  neighbor K8S-NODES soft-reconfiguration inbound
  !
  ! Only accept LB pool prefixes from nodes – safety filter
  ! Prevents accidentally learning pod CIDRs or node routes
  neighbor K8S-NODES prefix-list cluster-lb-only in
  !
  ! Never advertise UDR7 routes back to cluster nodes
  neighbor K8S-NODES prefix-list DENY-ALL out
 exit-address-family
!
! Accept only prefixes within the LB pool range
ip prefix-list cluster-lb-only seq 10 permit 10.10.1.200/24 le 32
ip prefix-list cluster-lb-only seq 20 deny any
!
! Outbound: advertise nothing to cluster nodes
ip prefix-list DENY-ALL seq 10 deny any
!
line vty
!

:warning: Apply the FRR config on the Ubiquiti Dream Router first, then enable BGP in Cilium. Otherwise BGP neighbors sit in Active state during rollout and your LB IPs are unreachable while you wait. With this order, sessions establish in ~5 seconds.

Verify BGP is working

$ cilium bgp peers
# Node: dev-cluster-node-1   Peer: 10.10.1.1   Session: established
# Node: dev-cluster-node-2   Peer: 10.10.1.1   Session: established
# Node: dev-cluster-node-3   Peer: 10.10.1.1   Session: established

:safety_pin: 10.10.1.100 (VIP) vs. 10.10.1.200–249 (LB pool) — two completely different mechanisms:

  • 10.10.1.100 is a Talos-native L2 floating IP. Talos runs internal leader election backed by etcd — the winning node assigns the IP directly to its NIC 1 interface. If that node goes down, another node wins and the IP moves. The router never sees a BGP route for it, just an ARP from whichever node currently holds it. Used exclusively for kube-apiserver + talosctl access.

  • 10.10.1.200–249 are BGP-advertised LoadBalancer IPs. All three nodes advertise each IP simultaneously. The router installs 3 ECMP routes per IP — true layer 3 load balancing, no ARP involved.

This is also why the BGP inbound prefix-filter below only permits 10.10.1.200/24 le 3210.10.1.100 never appears in BGP at all, so no rule for it is needed or wanted.


:hammer_and_wrench: Phase 8 — Storage and GitOps

Flux + SOPS-age

My GitOps repo mirrors the same infrastructure/ / operators/ / applications/ layout I run in production. Same stack, same structure.

export GITEA_TOKEN=<your-token>
flux bootstrap gitea \
  --components-extra=image-reflector-controller,image-automation-controller \
  --hostname=git.my.domain \
  --token-auth=true \
  --owner=user \
  --repository=Kubernetes-Dev \
  --branch=main \
  --path=./flux-system \
  --personal --private

# Inject SOPS age key so Flux can decrypt secrets
kubectl create secret generic sops-age \
  --namespace=flux-system \
  --from-file=age.agekey=/path/to/age.agekey

# Force first reconcile
flux reconcile kustomization flux-system --with-source

The rest installs itself from Git. Cilium, Longhorn, Gateway API — Flux pulls them in order.

Longhorn

Longhorn uses all three Crucial P510s as its data plane. Three nodes = three replicas = you can lose any single node and lose zero data. Because we’re on real PCIe 3.0 NVMe, Longhorn’s random IOPS are actually usable for databases.

The kernel modules from longhorn.yaml (Phase 6) are already loaded by the time Longhorn installs — it just works.

apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: longhorn
  namespace: longhorn-system
spec:
  interval: 1h
  chart:
    spec:
      chart: longhorn
      version: "1.11.1"
      sourceRef:
        kind: HelmRepository
        name: longhorn
        namespace: flux-system
      interval: 1h
  releaseName: longhorn
  install:
    crds: CreateReplace
    remediation:
      retries: 5
  upgrade:
    crds: CreateReplace
    remediation:
      retries: 3
  dependsOn:
    - name: cilium
      namespace: kube-system   # wait until nodes are Ready before scheduling Longhorn
  values:
    defaultSettings:
      defaultDataLocality: best-effort
      replicaSoftAntiAffinity: "true"
      replicaAutoBalance: best-effort
      # Keep 20% free on each disk minimum
      storageMinimalAvailablePercentage: 20
      allowCollectingLonghornUsageMetrics: false
      upgradeChecker: false
      # V2 Data Engine — requires hugepages + kernel modules + sperate block disks on each node
      # Do NOT enable until Talos machine config patch has been applied and nodes rebooted
      v2DataEngine: false
      networkPolicies:
        enabled: true
        type: "k3s"  # k3s = grant kube-system namespace access future Longhorn UI
    persistence:
      defaultClass: true
      dataEngine: v1
      defaultClassReplicaCount: 3
      defaultDataLocality: best-effort
      reclaimPolicy: Retain

:link: The full stack at a glance

Layer Component
OS Talos Linux v1.12.8 (immutable, API-only, no SSH)
Kubernetes v1.33.12 - all 3 nodes are control-plane + worker
CNI Cilium 1.17+ - eBPF, kube-proxy replacement
LoadBalancer Cilium BGP Control Plane → ECMP 3-way on Ubiquiti Dream Router
Ingress Gateway API (Cilium native) — no deprecated ingress-nginx
Storage Longhorn — replicated NVMe, 3 replicas
Secrets SOPS + age, encrypted in Git
GitOps Flux + image automation

:camera: Gallery


:folded_hands: Thank you, @radxa

None of this would be remotely possible without the Rock 5T being what it is. The combination of dual native 2.5 GbE, a real PCIe 3.0 M.2 2280 slot, 24 GB LPDDR5, a working PoE+ module and — critically — a first-class Talos Linux overlay maintained alongside upstream releases makes this board genuinely special in the SBC ecosystem, in my point of view.


I’m happy to answer questions about reproducing any part of this and hoping for feedback and suggestions for v2 :raising_hands:

3 Likes

Pretty sick stuff. You might want to post it to r/homelab.

1 Like

:thermometer: Temperature & noise solved in Kubernetes

several people asked about thermals and fan noise, so here’s what I’m running.

Rather than configuring fan control at the OS level, I run it as a Kubernetes DaemonSet. The cluster manages its own fans. Radxa Heatsink 6240B

How it works

A privileged alpine container mounts /sys, takes over the thermal zone with user_space policy, and runs a 2-second control loop directly against /sys/devices/platform/pwm-fan/hwmon/hwmon*/pwm1. No packages, no daemon, no OS config — just a shell loop and sysfs.

It auto-discovers both the correct thermal zone (looks for soc|package|cpu|tsadc) and the PWM path, so it survives node reboots without hardcoding paths.

Fan curve — NVMe underside cooling aware

The M.2 slot sits on the underside of the board, directly under the fan. The curve accounts for that: it doesn’t run fully fanless even at low SoC temps, to keep the NVMe cool too. The noise labels are my subjective impression at normal >1 meter distance:

SoC temp PWM Character
< 50 °C 0 Off
50 °C 30 absolutly silent — NVMe soak prevention
55 °C 45 Barely perceptible
60 °C 55 Quiet
65 °C 60 Relatively quiet — target temp
70 °C 150 Audible active cooling
75 °C 210 Strong
≥ 80 °C 255 Max — before 85 °C throttle

A 2 °C hysteresis prevents rapid toggling at thresholds: ramps up immediately when a threshold is crossed upward, only ramps back down once temperature has dropped 2 °C below the trigger point.

The DaemonSet
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fan-control
  namespace: fan-control
spec:
  selector:
    matchLabels:
      name: fan-control
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 100%
  template:
    metadata:
      labels:
        name: fan-control
    spec:
      tolerations:
        - key: node-role.kubernetes.io/control-plane
          effect: NoSchedule
      hostPID: true
      hostNetwork: true
      containers:
        - name: fan-control
          image: alpine:latest
          securityContext:
            privileged: true
          volumeMounts:
            - name: sys
              mountPath: /sys
          command:
            - /bin/sh
            - -c
            - |
              set -e

              THERMAL_ZONE=""
              for tz in /sys/class/thermal/thermal_zone*; do
                type=$(cat "$tz/type" 2>/dev/null || true)
                if echo "$type" | grep -qi "soc\|package\|cpu\|tsadc"; then
                  THERMAL_ZONE="$tz"; break
                fi
              done
              THERMAL_ZONE="${THERMAL_ZONE:-/sys/class/thermal/thermal_zone0}"
              echo "Using thermal zone: $THERMAL_ZONE (type: $(cat $THERMAL_ZONE/type))"

              PWM_FAN=""
              for hwmon in /sys/devices/platform/pwm-fan/hwmon/hwmon*/pwm1; do
                [ -w "$hwmon" ] && PWM_FAN="$hwmon" && break
              done
              if [ -z "$PWM_FAN" ]; then
                echo "ERROR: pwm-fan sysfs node not found. Is the pwm-fan driver loaded?"
                sleep 60; exit 1
              fi
              echo "Using PWM fan: $PWM_FAN"

              echo user_space > "$THERMAL_ZONE/policy"

              echo "---"
              echo "Configured fan curve:"
              echo "   < 50°C  →  PWM   0  (off)"
              echo "    50°C   →  PWM  30  (silent - NVMe soak prevention)"
              echo "    55°C   →  PWM  45  (barely perceptible)"
              echo "    60°C   →  PWM  55  (quiet)"
              echo "    65°C   →  PWM  60  (relatively quiet - target temp)"
              echo "    70°C   →  PWM 150  (audible active cooling)"
              echo "    75°C   →  PWM 210  (strong)"
              echo "  >= 80°C  →  PWM 255  (max - before 85°C throttle)"
              echo "---"

              HYSTERESIS=2000
              CURRENT_PWM=0
              TICK=0
              LOG_EVERY=15

              target_pwm() {
                t=$1
                if   [ "$t" -lt 50000 ]; then echo 0
                elif [ "$t" -lt 55000 ]; then echo 30
                elif [ "$t" -lt 60000 ]; then echo 45
                elif [ "$t" -lt 65000 ]; then echo 55
                elif [ "$t" -lt 70000 ]; then echo 60
                elif [ "$t" -lt 75000 ]; then echo 150
                elif [ "$t" -lt 80000 ]; then echo 210
                else                          echo 255
                fi
              }

              while true; do
                TEMP=$(cat "$THERMAL_ZONE/temp" 2>/dev/null || echo 50000)
                RISING_PWM=$(target_pwm "$TEMP")
                FALLING_PWM=$(target_pwm "$((TEMP + HYSTERESIS))")
                if   [ "$RISING_PWM"  -gt "$CURRENT_PWM" ]; then CURRENT_PWM=$RISING_PWM
                elif [ "$FALLING_PWM" -lt "$CURRENT_PWM" ]; then CURRENT_PWM=$RISING_PWM
                fi
                echo "$CURRENT_PWM" > "$PWM_FAN"
                TICK=$((TICK + 1))
                if [ $((TICK % LOG_EVERY)) -eq 0 ]; then
                  TEMP_C=$((TEMP / 1000))
                  TEMP_FRAC=$(( (TEMP % 1000) / 100 ))
                  echo "Temp: ${TEMP_C}.${TEMP_FRAC}°C | PWM: ${CURRENT_PWM}/255"
                fi
                sleep 2
              done
      volumes:
        - name: sys
          hostPath:
            path: /sys

The fan curve lives in Git and deploys via Flux like everything else — updating it is just a commit. No SSH, no per-node OS config, very Talos-friendly.

:bar_chart: Note on graphs: I’ll add proper performance, utilization and temperature graphs in a follow-up. Right now the cluster is running fewer applications than it eventually will — it’s a dev cluster and I’m actively testing and reconfiguring things, so the current numbers wouldn’t be representative of a steady-state workload. Once it’s settled I’ll share real data.

Looks awesome and compact, I still have mine cluster based on 16/32GB of 5B. Single ethernet, but easy to change eMMC so it can keep only system itself. For me longhorn was a failure, it was never stable enough and put way too much overhead into simple stuff. On v1 they use old iscsi, on v2 they require core per node just to maintain low latency. Longhorn also failed on several updates braking volumes :slight_smile: I’ll give it a try for sure in future, but for now I can make better strategy for my purpose.

1 Like

I can see that you don’t have a case. To fix the high nvme temperatures, I used a Rock 5B case by KKSB and coupled the SSD on the underside to the case using 2 thermal pads with a piece of copper between them. This brought down the SSD temperatures under load by almost 20 degrees.

A case for the 5T like the KKSB for the 5B would be really nice. I only found the Radxa Metal Case 1290 but it doesn’t fit very well with the soldered Radxa 25W PoE+ Module 5514, and I think it would be even worse in terms of temperatures.

Great work! This was my reference k8s stack some years ago, glad to see Talos have first class overlays on the rk3588.

Is the kernel based off bsp or upstream? Curious if I can to migrate my LLM work to this.

Unfortunately Upstream… Talos runs a mainline kernel (6.18.x in current releases), not the Rockchip BSP.

There’s a siderolabs/rockchip-rknn system extension, but despite the name it only packages the upstream rocket driver (drivers/gpu/drm/accel/rocket/), exposing the NPU as /dev/accel0. It does not ship Rockchip’s vendor rknpu2 driver. The two are completely different software paths to the same hardware:

  • Upstream rocket/dev/accel0, generic DRM/accel UAPI, and there’s no ML inference userspace for it today.
  • Vendor rknpu (what Armbian/BSP ships) → /dev/dri/renderD* + /dev/dma_heap/*, paired with librknnrt.so / rknn-llm.

My opinion on this topic, hope it helps:

Pretty much every RKNN project (in my case Immich’s Hardware-Accelerated Machine Learning -rknn image) bundles the vendor librknnrt, which sends vendor ioctls and cannot drive rocket — they’re not compatible. So in my case Talos is effectively not the best solution for real ML workloads on NPU.

If your LLM stack depends on the NPU (as most RK3588 LLM work does), keep that node on the BSP kernel. Talos is fantastic for cluster workloads, but vendor-accelerator stacks are the one spot its mainline-only stance doesn’t work for.

1 Like