This is the deployment manual for a three-node RKE2 control plane. It has three deliberate design choices worth stating up front, because they drive almost every command below:

  1. A control-plane-only VIP. The virtual IP is used for the API server and nothing else. Application traffic is kept completely separate.
  2. Cilium in native routing mode. Packets are routed directly between nodes instead of being wrapped in VXLAN, so there is no per-packet encapsulation overhead.
  3. A custom Pod CIDR. The Pod network is moved off the RKE2 default to 10.142.0.0/16.

Target systems: Ubuntu 24.04 or 22.04, layer 2 networking (all nodes in the same broadcast domain).

Node and IP planning

RoleHostnamePhysical IPNotes
VIP10.2.0.130API server HA only
Master 1k8s-m110.2.0.129Initial bootstrap node
Master 2k8s-m210.2.0.128HA node
Master 3k8s-m310.2.0.127HA node
  • Network interface: ens192 (adjust to match your hardware)
  • Pod CIDR: 10.142.0.0/16 (custom)
  • Service CIDR: 10.143.0.0/16

The Service CIDR is not a cosmetic setting. Once the cluster is up you cannot change it without rebuilding, so pick it once and keep every node consistent.


1. Base environment (all nodes)

Run every step in this section on all three machines — 10.2.0.129, 10.2.0.128, and 10.2.0.127.

1.1 System cleanup and kernel parameters

Cilium native routing depends on a clean network stack. Leftover iptables rules from a previous Kubernetes install will silently interfere with eBPF programs, so clear them out first.

# 1. Turn off the host firewall
systemctl disable --now ufw

# 2. Flush all iptables rules (stale rules break eBPF)
iptables -F && iptables -t nat -F && iptables -t mangle -F && iptables -X
iptables -P INPUT ACCEPT
iptables -P FORWARD ACCEPT
iptables -P OUTPUT ACCEPT
# On Ubuntu 24+, reset nftables as well
nft flush ruleset

# 3. Enable IP forwarding (required by native routing)
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
sysctl -p

1.2 Add a hosts entry to prevent a deadlock

This one is easy to skip and painful to debug. Kube-VIP resolves the kubernetes name during startup; if DNS is not up yet, it crashes and the VIP never appears.

echo "127.0.0.1 kubernetes" >> /etc/hosts

1.3 Point crictl at the bundled containerd

cat <<EOF > /etc/crictl.yaml
runtime-endpoint: unix:///run/k3s/containerd/containerd.sock
image-endpoint: unix:///run/k3s/containerd/containerd.sock
timeout: 10
debug: false
EOF

2. Deploy the first master (10.2.0.129 only)

2.1 Create the RKE2 config file

mkdir -p /etc/rancher/rke2/

cat <<EOF > /etc/rancher/rke2/config.yaml
tls-san:
  - "10.2.0.130"
cni: none                       # disable the default CNI
disable-kube-proxy: true        # disable kube-proxy (Cilium takes over)
etcd-expose-metrics: true
cluster-cidr: "10.142.0.0/16"   # custom Pod network
service-cidr: "10.143.0.0/16"   # custom Service network
EOF

2.2 Deploy the Kube-VIP static pod

Note the absence of svc_enable. That omission is intentional: it keeps the VIP bound to the control plane only, so no application traffic ever lands on it.

mkdir -p /var/lib/rancher/rke2/agent/pod-manifests/

cat <<EOF > /var/lib/rancher/rke2/agent/pod-manifests/kube-vip.yaml
apiVersion: v1
kind: Pod
metadata:
  name: kube-vip
  namespace: kube-system
spec:
  containers:
  - name: kube-vip
    image: ghcr.io/kube-vip/kube-vip:v1.0.3
    imagePullPolicy: IfNotPresent
    args:
    - manager
    - --vipSubnet
    - "32"
    env:
    - name: vip_arp
      value: "true"
    - name: vip_interface
      value: "ens192"       # confirm your interface name
    - name: vip_address
      value: "10.2.0.130"   # control plane VIP
    - name: cp_enable
      value: "true"
    - name: cp_namespace
      value: "kube-system"
    - name: vip_leaderelection
      value: "true"
    - name: KUBECONFIG
      value: "/etc/kubernetes/admin.conf"
    securityContext:
      capabilities:
        add:
        - NET_ADMIN
        - NET_RAW
    volumeMounts:
    - mountPath: /etc/kubernetes/admin.conf
      name: kubeconfig
  hostNetwork: true
  volumes:
  - hostPath:
      path: /etc/rancher/rke2/rke2.yaml
    name: kubeconfig
EOF

2.3 Start RKE2 and install kubectl

# Install and start
curl -sfL https://get.rke2.io | sh -
systemctl enable rke2-server
systemctl start rke2-server

# Configure kubectl
mkdir -p ~/.kube
cp /etc/rancher/rke2/rke2.yaml ~/.kube/config
chmod 600 ~/.kube/config
export KUBECONFIG=~/.kube/config
ln -s /var/lib/rancher/rke2/bin/kubectl /usr/local/bin/kubectl

RKE2 generates its kubeconfig at /etc/rancher/rke2/rke2.yaml, not the more familiar /etc/kubernetes/admin.conf. The Kube-VIP manifest above mounts the RKE2 path onto the kubeadm path inside the container, because Kube-VIP hard-codes the latter.


3. Install Cilium

Run on 10.2.0.129 only, after the VIP responds. Confirm it does before continuing — installing Cilium with k8sServiceHost pointing at an address that is not yet up will leave the CNI unable to bootstrap.

3.1 Install Helm

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm repo add cilium https://helm.cilium.io/
helm repo update

3.2 Deploy Cilium in native routing mode

The critical detail is ipv4NativeRoutingCIDR. It must match the cluster-cidr you gave RKE2, otherwise the two components will disagree about which addresses are local Pods and traffic will be misrouted.

helm install cilium cilium/cilium \
   --namespace kube-system \
   --set kubeProxyReplacement=true \
   --set k8sServiceHost=10.2.0.130 \
   --set k8sServicePort=6443 \
   --set cni.exclusive=false \
   --set routingMode=native \
   --set autoDirectNodeRoutes=true \
   --set ipv4NativeRoutingCIDR=10.142.0.0/16 \
   --set bpf.masquerade=true \
   --set hubble.relay.enabled=true \
   --set hubble.ui.enabled=true
  • kubeProxyReplacement=true works together with disable-kube-proxy: true in the RKE2 config. Turn on one without the other and Service routing breaks.
  • cni.exclusive=false is required on RKE2, which already ships its own CNI configuration files.
  • bpf.masquerade=true moves SNAT from iptables into eBPF, which is the main throughput win over the default setup.

4. Join the HA nodes

4.1 Get the node token (on master 1)

cat /var/lib/rancher/rke2/server/node-token
# copy the output

4.2 Configure the joining nodes (128 and 127)

mkdir -p /etc/rancher/rke2/

# replace <TOKEN> with the value from the previous step
cat <<EOF > /etc/rancher/rke2/config.yaml
server: https://10.2.0.130:9345    # point at the VIP
token: <TOKEN>
tls-san:
  - "10.2.0.130"
cni: none
disable-kube-proxy: true
cluster-cidr: "10.142.0.0/16"      # must match master 1
EOF

Note port 9345, not 6443. That is the RKE2 supervisor API used for node registration; 6443 is the Kubernetes API itself.

4.3 Preload Kube-VIP (128 and 127)

This step is mandatory. Copy /var/lib/rancher/rke2/agent/pod-manifests/kube-vip.yaml from master 1 to the exact same path on each joining node, before starting RKE2. Without it the new nodes cannot take part in the VIP leader election.

4.4 Start RKE2

curl -sfL https://get.rke2.io | sh -
systemctl enable rke2-server
systemctl start rke2-server

5. Verification

5.1 Cluster state

kubectl get nodes -o wide
# expect: all nodes Ready

5.2 Confirm the routing mode

kubectl -n kube-system exec -ti ds/cilium -- cilium status | grep -E "Routing|Masquerading"
# expected:
# Routing:        Network: Native   Host: BPF
# Masquerading:   BPF   [ens192]   10.142.0.0/16

5.3 Deploy a test Pod to validate the new CIDR

kubectl create deployment nginx-test --image=nginx:alpine --replicas=2
kubectl get pods -l app=nginx-test -o wide
# expected IP: 10.142.x.x

6. Optional: direct Pod access from your workstation

With native routing there is no encapsulation, so a Pod IP is a real routable address on the LAN. If your workstation is on the same layer 2 network, you can teach it the route and then reach Pods directly — useful for debugging a service without going through a Service or Ingress.

# On macOS (needs sudo)
# Send anything destined for 10.142.x.x to the VIP
sudo route -n add -net 10.142.0.0/16 10.2.0.130
ping 10.142.x.x

This is a convenience for a lab or a trusted office network, not something to do from an untrusted machine. On Linux the equivalent is ip route add 10.142.0.0/16 via 10.2.0.130; on Windows, route add 10.142.0.0 mask 255.255.0.0 10.2.0.130.