Introduction

RKE2 runs fine on plain Linode instances, but without a cloud provider the cluster knows nothing about the platform underneath it. Service type=LoadBalancer stays <pending> forever, nodes have no provider ID or region labels, and there is no way to get a Block Storage Volume attached to a pod.

Two upstream projects fill that gap:

  • Linode Cloud Controller Manager (CCM): sets node addresses and region/zone labels, and turns LoadBalancer services into NodeBalancers.
  • Linode Block Storage CSI driver: provisions, attaches and resizes Block Storage Volumes for PersistentVolumeClaims.

This guide covers:

  • Preparing the Linode instances (and the localhost hostname trap)
  • Configuring RKE2 for an external cloud provider
  • Installing the CCM with Helm
  • Using NodeBalancer annotations
  • Installing the CSI driver and using its StorageClasses

Prerequisites

  • Linode instances in a single region for the RKE2 nodes.
  • A Linode APIv4 Personal Access Token. The CCM chart documents the token as needing all permissions. Treat it like a root credential for the account and keep it out of Git.
  • kubectl and helm on your workstation.
  • Kubernetes 1.22 or newer (the CCM’s documented minimum; any current RKE2 release qualifies).

Note the region ID (for example us-east), since both charts need it:

bash
linode-cli regions list --text --format id,label

Step 1: Fix Node Hostnames Before Installing RKE2

Some Linode images boot with the hostname localhost. The RKE2 docs call this out specifically for Linode: every node registering as localhost collides, and the CCM cannot match nodes to instances.

Either set a real hostname on each instance:

bash
hostnamectl set-hostname rke2-cp-1

or pin the node name in the RKE2 config (Step 2). Name the Kubernetes node the same as the Linode label. The CCM looks up instances by name, so a mismatch leaves nodes uninitialized.


Step 2: Configure RKE2 for an External Cloud Provider

Disable RKE2’s built-in cloud controller and tell every Kubernetes component that an external provider is in charge.

Standalone RKE2 (/etc/rancher/rke2/config.yaml)

On server (control plane) nodes:

yaml
node-name: rke2-cp-1
disable-cloud-controller: true
kube-apiserver-arg:
  - cloud-provider=external
kube-controller-manager-arg:
  - cloud-provider=external
kubelet-arg:
  - cloud-provider=external

On agent (worker) nodes:

yaml
node-name: rke2-worker-1
kubelet-arg:
  - cloud-provider=external

Rancher-provisioned clusters

In the cluster YAML, use machineSelectorConfig to apply the same settings by role:

yaml
spec:
  rkeConfig:
    machineSelectorConfig:
      - config:
          disable-cloud-controller: true
          kube-apiserver-arg:
            - cloud-provider=external
          kube-controller-manager-arg:
            - cloud-provider=external
          kubelet-arg:
            - cloud-provider=external
        machineLabelSelector:
          matchExpressions:
            - key: rke.cattle.io/control-plane-role
              operator: In
              values:
                - 'true'
      - config:
          kubelet-arg:
            - cloud-provider=external
        machineLabelSelector:
          matchExpressions:
            - key: rke.cattle.io/worker-role
              operator: In
              values:
                - 'true'

With cloud-provider=external, the kubelet registers each node with the taint node.cloudprovider.kubernetes.io/uninitialized=true:NoSchedule. Ordinary workloads will not schedule until the CCM initializes the node, so install the CCM next.


Step 3: Install the Linode Cloud Controller Manager

Add the chart repository:

bash
helm repo add ccm-linode https://linode.github.io/linode-cloud-controller-manager/
helm repo update ccm-linode

Create ccm-values.yaml. Keep this file out of version control, or use a Secret you manage elsewhere (see the note below):

yaml
apiToken: "<your-linode-api-token>"
region: "us-east"

Install into kube-system:

bash
helm install ccm-linode \
  --namespace kube-system \
  -f ccm-values.yaml \
  ccm-linode/ccm-linode

The chart’s default tolerations already cover the control-plane taint, CriticalAddonsOnly, and node.cloudprovider.kubernetes.io/uninitialized. It schedules on a fresh RKE2 cluster without extra values.

Managing the token as a Secret: the chart can read the token and region from an existing Secret through secretRef (defaults: name ccm-linode, keys apiToken and region). That fits clusters where secrets come from External Secrets, Sealed Secrets or Vault rather than Helm values. The Secret must live in the same namespace as the release.

Verify

bash
kubectl -n kube-system get pods -l app=ccm-linode
kubectl get nodes -o custom-columns=NAME:.metadata.name,PROVIDER:.spec.providerID,REGION:.metadata.labels.topology\.kubernetes\.io/region
kubectl describe nodes | grep -i taints

Every node should show a linode:// provider ID and the region label, and the uninitialized taint should be gone. If a node keeps the taint, check the CCM logs (kubectl -n kube-system logs -l app=ccm-linode). The usual cause is a node name that does not match the Linode label (Step 1).


Step 4: Expose Services with NodeBalancers

Any Service of type: LoadBalancer now gets a NodeBalancer:

yaml
apiVersion: v1
kind: Service
metadata:
  name: web
  annotations:
    service.beta.kubernetes.io/linode-loadbalancer-default-protocol: "http"
    service.beta.kubernetes.io/linode-loadbalancer-check-type: "http"
    service.beta.kubernetes.io/linode-loadbalancer-check-path: "/healthz"
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 8080
bash
kubectl get svc web -w

The useful annotations, all prefixed service.beta.kubernetes.io/linode-loadbalancer-:

AnnotationDefaultUse
default-protocoltcptcp, udp, http or https
default-proxy-protocolnonev1 or v2 to pass client IPs to an ingress controller
check-typeconnection (none for UDP)none, connection, http, http_body
check-path / check-bodynonePath and expected body for HTTP checks
throttle0Connection throttle per client IP, 0 to 20 (0 disables)
firewall-idnoneAttach an existing Cloud Firewall
firewall-aclnoneHave the CCM manage a firewall from a JSON allowList or denyList (not both)
preservefalseKeep the NodeBalancer when the Service is deleted
nodebalancer-idnoneAdopt an existing NodeBalancer instead of creating one
reserved-ipv4noneUse a reserved IPv4 address
backend-vpc-name / backend-subnet-namenoneSend backend traffic over a VPC subnet
tagsnoneComma-separated Linode tags for the NodeBalancer

Two details trip people up:

  • Annotation values must be strings. Quote numbers and booleans ("true", "20"), or the API server rejects the manifest.
  • Proxy protocol must match on both ends. If you set default-proxy-protocol: "v2" on the Service in front of ingress-nginx, enable use-proxy-protocol in the ingress controller too. Otherwise every request fails with a bad request or a broken connection.

For an ingress controller, preserve: "true" combined with reserved-ipv4 keeps the public IP (and your DNS records) stable across reinstalls.


Step 5: Install the Linode Block Storage CSI Driver

Add the repository and install, passing the same token and region:

bash
helm repo add linode-csi https://linode.github.io/linode-blockstorage-csi-driver/
helm repo update linode-csi

helm install linode-csi-driver \
  --namespace kube-system \
  --set apiToken="${LINODE_API_TOKEN}" \
  --set region="${REGION}" \
  linode-csi/linode-blockstorage-csi-driver
bash
kubectl -n kube-system get pods | grep csi-linode
kubectl get storageclass

The driver creates two StorageClasses with provisioner linodebs.csi.linode.com:

StorageClassReclaim policyNotes
linode-block-storage-retainRetainCluster default
linode-block-storageDeleteVolume is destroyed with the PVC

The default is Retain, so deleting a PVC leaves the Volume in your account, and on your bill. That is the safe choice for databases. For scratch or CI workloads, request linode-block-storage explicitly, or clean up released PVs on a schedule.

Test with a claim. Block Storage has a 10Gi minimum, so smaller requests are rounded up:

yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: linode-block-storage-retain
  resources:
    requests:
      storage: 10Gi

To grow a volume, raise spec.resources.requests.storage on the PVC. The StorageClasses allow expansion.

Encrypted volumes

For encryption at rest, create your own StorageClass with the Block Storage encryption parameter:

yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: linode-block-storage-retain-encrypted
provisioner: linodebs.csi.linode.com
reclaimPolicy: Retain
allowVolumeExpansion: true
parameters:
  linodebs.csi.linode.com/encrypted: "true"

Encryption applies only to new, empty volumes, and not every region offers it. The driver returns an error rather than silently creating an unencrypted volume. The driver also supports LUKS encryption with a key from a Kubernetes Secret (linodebs.csi.linode.com/luks-encrypted), but then key rotation is your job.


Troubleshooting

SymptomLikely cause
Nodes stuck with node.cloudprovider.kubernetes.io/uninitializedCCM not running, or node name differs from the Linode label
Several nodes named localhostHostname never set; see Step 1
LoadBalancer stays <pending>Token lacks permissions, wrong region, or CCM crashlooping; check kubectl -n kube-system logs -l app=ccm-linode
NodeBalancer shows all backends downHealth check type or path does not match the app; try check-type: "connection" first
PVC stays PendingCSI controller cannot reach the API, or the region does not match the nodes
Volume will not attachVolume and instance are in different regions, or the instance hit its attachment limit

Conclusion

With the CCM and the CSI driver installed, RKE2 on Linode behaves like a managed cluster: LoadBalancer services get NodeBalancers with real health checks and firewalls, and PersistentVolumeClaims get Block Storage Volumes that survive pod and node replacement.

The upstream projects are linode-cloud-controller-manager and linode-blockstorage-csi-driver. Their docs/ directories have the full annotation and parameter reference.


Want more Kubernetes insights? Browse the RKE2 Training series for expert insights!