π Installation
π Table of Contents
- Requirements
- Secure a Linode API access token
- Check your cluster first
- Create an NFS Storage Space
- Deployment methods
- Verify the installation
- Upgrading the driver
- Uninstalling the driver
- Optional configuration
π§ Requirements
| Requirement | Detail |
|---|---|
| Kubernetes | A cluster with the CSI feature set enabled |
| Linode Cloud Controller Manager | Required. The driver reads spec.providerID (linode://12345) off node objects to resolve Linode IDs |
| Region topology labels | Nodes must carry topology.kubernetes.io/region (or the beta label). The CCM sets these |
| A VPC | Every node must be attached to a VPC. The driver requires VPC-backed IPv6 connectivity to reach mount targets |
| A single region | All workload nodes must be in one region. The controller discovers it from one Node on first provisioning and caches it for the process lifetime |
| An NFS Storage Space | Pre-created. The driver provisions filesystems inside it but never creates the space |
| NFSv4 kernel support | The node kernel must support NFSv4. By default the driver mounts with vers=4.1,proto=tcp6; mountOptions on the StorageClass replace those defaults. The NFS client tools ship in the plugin image, so nothing needs installing on the host |
LINODE_TOKEN | A Personal Access Token with read/write access to NFS resources |
VPC-backed IPv6 connectivity and a correct Space access policy are required for mounts. The driver does not discover a VPC or enforce customer Space ACLs during provisioning. See Access and Networking.
π Secure a Linode API access token
Generate a Personal Access Token (PAT) in the Linode Cloud Manager. The controller uses it for every Linode API call, so scope it to what the driver actually touches:
- NFS / file storage: read/write, to create and delete filesystems and update filesystem access policies. The controller only reads Storage Space access policies
The driver makes no Linode instance, interface, or VPC discovery calls.
Give it an expiry long enough for continued use, and plan to rotate it. The token is injected as the LINODE_TOKEN environment variable, so a rotation requires restarting the controller pod.
The node DaemonSet does not receive the token and does not call the Linode API.
π©Ί Check your cluster first
Confirm that workload nodes have the provider IDs and region labels used by node identity and controller region discovery. Check their VPC and subnet assignments separately for mount reachability (see Requirements).
kubectl get nodes -o custom-columns=\
NAME:.metadata.name,\
PROVIDER:.spec.providerID,\
REGION:'.metadata.labels.topology\.kubernetes\.io/region'
Every node should show a linode:// provider ID and the same region. The controller requests one workload Node and caches its region after the first successful lookup; it does not compare all nodes. No region installation value is required, and the controller can start before workers exist. Failed discovery can retry on a later provisioning request.
Find the workload VPC ID in Cloud Manager and give it to the Space owner for access-policy configuration. The driver does not perform that lookup.
π Create an NFS Storage Space
The driver never creates a Storage Space. Create one for your cluster, then note either its numeric ID or its label; every StorageClass must name one or the other.
Before creating a PVC, ask the Space owner to enable the Space access policy and add the cluster VPC to its vpc_acl. The driver neither validates nor changes this policy. If it is wrong, PVCs still bind, but pods stay ContainerCreating with mount.nfs4 errors. See Space access policy.
Create it in the Cloud Manager, or against the API directly:
curl -sS -X POST \
-H "Authorization: Bearer ${LINODE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"label": "my-cluster-space"}' \
https://api.linode.com/v4beta/nfs/spaces
The response is the new Space, and its id is the Space ID. A response looks like this (the values are examples):
{
"id": 42,
"label": "my-cluster-space",
"description": null,
"status": "active",
"created": "2026-05-01T12:00:00Z",
"updated": "2026-05-01T12:00:00Z",
"tags": []
}
Creating a Space does not provision backend resources right away. They are provisioned when the first filesystem is created in a region, so the first volume you provision there can take longer than later ones.
To find the ID of an existing Space, list the Spaces on the account:
curl -sS \
-H "Authorization: Bearer ${LINODE_API_TOKEN}" \
https://api.linode.com/v4beta/nfs/spaces
You will reference it from a StorageClass as either:
parameters:
linodenfs.csi.linode.com/space-id: "42"
# or
linodenfs.csi.linode.com/space-label: "my-cluster-space"
The two are mutually exclusive, and one of them is required. See StorageClass parameters.
π Admit the cluster VPC on the Space
The driver reads the Space access policy for its mTLS mode and never changes or validates its ACL. A PVC can bind while the policy is disabled or does not admit the workload VPC; the failure appears when a pod mounts the volume. If you own the Space, configure it below. Otherwise, send the Space owner these steps and the workload VPC ID.
Read the current policy first. A new policy is disabled by default.
curl -sS \
-H "Authorization: Bearer ${LINODE_API_TOKEN}" \
https://api.linode.com/v4beta/nfs/spaces/${SPACE_ID}/access-policy
Then enable it and add the VPC ID from the cluster check. You can also do this in Cloud Manager; the curl commands call the API directly.
curl -sS -X PUT \
-H "Authorization: Bearer ${LINODE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"enabled\": true, \"vpcs\": [{\"id\": ${VPC_ID}}]}" \
https://api.linode.com/v4beta/nfs/spaces/${SPACE_ID}/access-policy
The PUT replaces the whole VPC list. If vpc_acl in the earlier response already has entries, include them in vpcs too, with their subnet IDs if they have any. Leaving subnets out admits every subnet in the VPC.
The Linode API applies the update asynchronously. The PUT response can already list the VPC while the policyβs status is updating. Run the GET again to confirm it is active before mounting. The driver does not wait for the Space policy during provisioning.
π§° Deployment methods
There are two ways to deploy the driver:
- Using Helm (recommended)
- Using kubectl with the released Kustomize manifest
The Helm chart in charts/linode-nfs-csi-driver is the source of truth. deploy/kubernetes/base is rendered from it by mise run update-kustomize, and CI fails if the two drift apart, so both paths deploy the same objects.
1. Using Helm
π Add the linode-nfs-csi repo
helm repo add linode-nfs-csi https://linode.github.io/linode-filestorage-csi-driver/
helm repo update linode-nfs-csi
Every tagged release publishes a versioned chart package and repository index to that site, so this is the only step that needs repeating when a new version lands.
π Deploy the CSI driver
export LINODE_API_TOKEN="...your Linode API token..."
helm install linode-nfs-csi-driver \
--namespace kube-system \
--set apiToken="${LINODE_API_TOKEN}" \
linode-nfs-csi/linode-nfs-csi-driver
Pin a version with --version, and use helm upgrade --install if you want the same command to work whether or not the release already exists.
Or install from a checkout of the repository:
export LINODE_TOKEN="...your Linode API token..."
mise run helm-install
mise run helm-install wraps helm upgrade --install against charts/linode-nfs-csi-driver, targeting the kube-system namespace and setting the image repository and tag from IMAGE_REPO and IMAGE_VERSION. It refuses to run without LINODE_TOKEN set.
See helm install for command documentation.
π Bring your own Secret
When secretRef is unset and apiToken is provided, the chart creates a Secret named linode-api-token in the release namespace with the token under the key token.
To point at a Secret you manage instead, set secretRef and leave apiToken empty:
# values.yaml
secretRef:
name: my-linode-secret
apiTokenRef: token # defaults to "token" when omitted
The chart then skips creating a Secret and wires LINODE_TOKEN to my-linode-secretβs token key.
π© Configuring the chart
Override values with --set var=value, or with a custom values file:
helm upgrade --install linode-nfs-csi-driver \
--namespace kube-system \
-f custom-values.yaml \
charts/linode-nfs-csi-driver
Using a values file is the recommended approach; --set on deeply nested keys is easy to get subtly wrong. For the full list of values, see the Configuration Reference or charts/linode-nfs-csi-driver/values.yaml.
2. Using kubectl
π Create the Secret first
The raw manifests do not create the API token Secret. Create it before applying them:
apiVersion: v1
kind: Secret
metadata:
name: linode-api-token
namespace: kube-system
stringData:
token: "your linode api token"
type: Opaque
kubectl apply -f secret.yaml
kubectl get secret linode-api-token -n kube-system
The Secret name and key are not configurable in the raw manifests: the controller expects linode-api-token in kube-system with a token key. Use Helm if you need different names.
π Apply the manifest
Apply the manifest attached to a release. It carries the release image tag:
# Use the latest tag from https://github.com/linode/linode-filestorage-csi-driver/releases
export VERSION="<release tag>"
kubectl apply -f https://github.com/linode/linode-filestorage-csi-driver/releases/download/${VERSION}/linode-filestorage-csi-driver-${VERSION}.yaml
Development only:
kustomize build deploy/kubernetes/base | kubectl apply -f -builds from a checkout, but the base pins the:devimage tag. The release workflow does not push that tag, so the image will not pull unless you build and push your own. Use the released manifest for normal installs.
There are also deploy/kubernetes/overlays/ci and deploy/kubernetes/overlays/dev overlays, both of which currently just include the base.
Do not hand-edit
deploy/kubernetes/base. It is generated. Change the Helm chart and runmise run update-kustomize.
β Verify the installation
kubectl -n kube-system get pods -l role=csi-linode
kubectl get csidrivers linodenfs.csi.linode.com
You should see the controller Deployment and one node pod per node:
NAME READY STATUS RESTARTS AGE
csi-linode-nfs-controller-6b8f9c4d7-x2klm 5/5 Running 0 45s
csi-linode-nfs-node-2rq8w 3/3 Running 0 45s
csi-linode-nfs-node-9fzp4 3/3 Running 0 45s
The controller has 5 containers by default (plugin, csi-provisioner, csi-attacher, csi-resizer, liveness-probe), plus csi-snapshotter if you enabled it. Each node pod has 3 (plugin, node-driver-registrar, liveness-probe).
Confirm the node plugins registered with their kubelets:
kubectl get csinodes -o custom-columns=NODE:.metadata.name,DRIVERS:.spec.drivers[*].name
Every node should list linodenfs.csi.linode.com. If one does not, see Troubleshooting.
β« Upgrading the driver
export LINODE_API_TOKEN="...your Linode API token..."
helm repo update linode-nfs-csi
helm upgrade linode-nfs-csi-driver \
--install \
--namespace kube-system \
--set apiToken="${LINODE_API_TOKEN}" \
linode-nfs-csi/linode-nfs-csi-driver
helm repo update first, or Helm upgrades to the newest chart it already knows about rather than the newest chart that exists.
Upgrades roll the controller Deployment and the node DaemonSet. Mounted volumes stay mounted across a node plugin restart, because the mounts live in the host mount namespace, not the plugin container. In-flight provisioning retries once the controller is back.
Change the access-policy mode
Set one authorization model for the installation:
# Default: Space access policy only, without CSI attachment.
helm upgrade linode-nfs-csi-driver \
--namespace kube-system \
--reuse-values \
--set accessPolicy.mode=space \
linode-nfs-csi/linode-nfs-csi-driver
# Optional compatibility mode; not currently recommended because per-node
# policy updates add severe scheduling and cleanup delays.
helm upgrade linode-nfs-csi-driver \
--namespace kube-system \
--reuse-values \
--set accessPolicy.mode=node \
linode-nfs-csi/linode-nfs-csi-driver
Use space mode unless per-node Linode ACLs are a hard requirement. node mode waits for an asynchronous filesystem policy update on every CSI publish and unpublish operation, and these updates serialize for a volume. The delay grows sharply when an RWX volume is used across many nodes.
Kubernetes treats CSIDriver.spec.attachRequired as immutable. To change modes, first stop workloads using this driver, delete the linodenfs.csi.linode.com CSIDriver, then run the Helm upgrade so it is recreated with the matching value.
When changing from node to space, existing filesystems can retain an enabled Linode ACL. Recreate or explicitly migrate those filesystem policies before restarting workloads. Changing from space to node needs no policy migration: the first publish enables the filesystem policy and adds its node.
See helm upgrade for command documentation.
π§Ή Uninstalling the driver
Delete workloads and PVCs before removing the driver. Once the controller is gone, nothing can process DeleteVolume, and PVs with a Delete reclaim policy will hang in Terminating with their finalizers unsatisfied.
kubectl delete pvc --all -n <your-namespace>
helm uninstall linode-nfs-csi-driver -n kube-system
Uninstalling does not remove:
- The NFS Storage Space, which the driver never owned.
- The Space access policy, which the driver never validates or changes.
- Filesystems whose PVs used the
Retainreclaim policy.
See helm uninstall for command documentation.
π§© Optional configuration
PVC expansion
sidecars:
resizer:
enabled: true
The resizer is enabled by default and handles PVC capacity increases. Set allowVolumeExpansion: true in the StorageClass as well. The driver rejects reductions; the Linode API enforces its maximum customer-manageable capacity and returns any rejection reason to Kubernetes.
ServiceAccount and RBAC toggles
The chart creates both ServiceAccounts and their ClusterRoles/ClusterRoleBindings by default, and that is the arrangement the driver is meant to run in:
controller:
serviceAccount:
enabled: true
name: csi-linode-nfs-controller
rbac:
enabled: true # false: skip the ClusterRole and ClusterRoleBinding
node:
serviceAccount:
enabled: true
name: csi-linode-nfs-node
rbac:
enabled: true
Leave serviceAccount.enabled at true. Setting it to false does not point the workload at a ServiceAccount you manage; the chart omits serviceAccountName from the pod spec entirely, so the pod runs as the namespaceβs default ServiceAccount and the RBAC the driver needs is not bound to it.
rbac.enabled: false is the supported toggle if you manage roles and bindings yourself. Bind them to the ServiceAccount names above.
The controller role needs PVs, PVCs, nodes, events, secrets, CSINodes, VolumeAttachments, StorageClasses, leases, and the snapshot API group. The node role needs only get, list, and watch on nodes, which is how the node plugin resolves its own Linode ID when instance metadata is unavailable.
Alternate kubelet path
Distributions that do not use /var/lib/kubelet need both values changed together:
kubeletPath: /var/lib/k0s/kubelet
image:
podsMountDir: /var/lib/k0s/kubelet
kubeletPath controls the plugin socket and registration directories; podsMountDir is the bidirectional host mount where pod volume directories live. Leaving them inconsistent produces a node plugin that registers but cannot publish.
Non-default Linode API endpoint
linodeURL: https://api.linode.com
linodeAPIVersion: v4beta
Both are read by linodego from the environment (LINODE_URL, LINODE_API_VERSION). The default API version is v4beta because the managed NFS endpoints are still beta.