Kubernetes StorageClass and Dynamic Volume Provisioning

Define a StorageClass, watch a PVC trigger dynamic PV creation with WaitForFirstConsumer, set a default class, compare reclaim policies, and verify mounts with the Rancher local-path provisioner.

Published

Updated

Read time 10 min read

Reviewed byDeepak Prasad

Kubernetes StorageClass selecting a provisioner to create a PersistentVolume for a PVC
Tested on Rocky Linux 10.2 (Red Quartz) workstation
Package kubectl 1.36.3
local-path-provisioner (rancher.io/local-path)
Applies to Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Raspberry Pi OS, elementary OS, Zorin OS, Parrot OS, MX Linux, RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora
Kubernetes cluster
Cert prep CKA
Lab environment Multi-node kubeadm cluster with containerd — install Kubernetes with kubeadm. Dynamic examples use Rancher local-path-provisioner in namespace local-path-storage.
Privilege Normal user for kubectl when kubeconfig is available
Scope Static vs dynamic provisioning, verify a working provisioner, StorageClass fields, create classes and dynamically provisioned PVCs, Immediate vs WaitForFirstConsumer, default StorageClass, reclaim policy on dynamic PVs, volume expansion limits for this provisioner, and common failures. Does not cover CSI driver installation in depth, static PV tutorials, snapshots, cloning, storage backend administration, or provider parameter encyclopedias.
Related guides Kubernetes volumes
Kubernetes StatefulSets

A StorageClass is how you publish storage policy to the cluster: which provisioner creates volumes, when they bind, and how they are reclaimed. This lab uses the Rancher local-path provisioner (rancher.io/local-path) so every class you create maps to a provisioner that is actually running.

IMPORTANT
This article covers StorageClass policy and dynamic PVC provisioning with one tested provisioner. It does not cover installing CSI drivers in depth, static PV walkthroughs, volume snapshots, volume cloning, or storage-array administration. For static PV and PVC binding, use PersistentVolume and PVC.

Static vs dynamic provisioning

Both models still end with a Pod mounting a PersistentVolumeClaim. The difference is who creates the PersistentVolume.

Static provisioning Dynamic provisioning
An administrator creates the PV first A provisioner creates the PV from a PVC
The PVC binds to an existing PV The PVC selects a StorageClass
Size, path, and backend details live mainly on the PV Policy lives on the StorageClass; parameters are provisioner-specific

Static binding is covered in PersistentVolume and PVC. Here the PVC asks for a class, and the named provisioner builds a matching PV.


Verify a dynamic provisioner exists

A StorageClass that names a missing provisioner only produces Pending claims. Confirm classes and the provisioner Pods before you write YAML.

List StorageClass objects:

bash
kubectl get storageclass

On this lab after local-path was installed:

output
NAME         PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  51s

The PROVISIONER column must match a controller that is actually registered. Check CSIDriver objects next — they describe CSI plugins, which is useful context even when your dynamic provisioner is an external controller rather than a CSIDriver entry:

bash
kubectl get csidriver
output
NAME            ATTACHREQUIRED   PODINFOONMOUNT   STORAGECAPACITY   TOKENREQUESTS   REQUIRESREPUBLISH   MODES       AGE
csi.tigera.io   true             true             false             <unset>         false               Ephemeral   26m

Calico's csi.tigera.io is an ephemeral CSI driver for Calico, not a PersistentVolume provisioner. Do not point a PersistentVolume StorageClass at it. Confirm the local-path Deployment is Ready:

bash
kubectl get pods -n local-path-storage
output
NAME                                      READY   STATUS    RESTARTS   AGE
local-path-provisioner-6d484fd799-c79sh   1/1     Running   0          71s

When that Pod is Running, rancher.io/local-path can create hostPath-backed PVs under /opt/local-path-provisioner on the selected node. If your cluster has no dynamic provisioner yet, install one your team supports, wait until its controller is Ready, then continue — this article does not walk CSI install depth.


Understand StorageClass fields

StorageClass is cluster-scoped. The fields you use most often are:

Field Role
provisioner Name of the volume plugin or external provisioner
parameters Provisioner-specific options (often empty for local-path)
reclaimPolicy Copied onto dynamically created PVs (Delete or Retain)
volumeBindingMode Immediate or WaitForFirstConsumer
allowVolumeExpansion Whether PVC resize is allowed for this class

parameters are not universal Kubernetes keys. Cloud CSI drivers use keys such as type or encrypted; local-path typically needs none. Always read your provisioner's docs before inventing keys.

This lab uses manifests like:

yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: lab-wffc
provisioner: rancher.io/local-path
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false

Replace rancher.io/local-path only with a provisioner string that already works in your cluster.


Create a StorageClass

Create the WaitForFirstConsumer class used for the main PVC lab:

bash
kubectl apply -f - <<'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: lab-wffc
provisioner: rancher.io/local-path
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false
EOF

The API returns storageclass.storage.k8s.io/lab-wffc created when the object is accepted. List classes again:

bash
kubectl get storageclass
output
NAME            PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
lab-immediate   rancher.io/local-path   Delete          Immediate              false                  1s
lab-retain      rancher.io/local-path   Retain          WaitForFirstConsumer   false                  0s
lab-wffc        rancher.io/local-path   Delete          WaitForFirstConsumer   false                  1s
local-path      rancher.io/local-path   Delete          WaitForFirstConsumer   false                  72s

lab-immediate and lab-retain are created the same way with different volumeBindingMode or reclaimPolicy values for the sections below. Describe one class to confirm fields:

bash
kubectl describe storageclass lab-wffc

Sample output:

output
Name:            lab-wffc
IsDefaultClass:  No
Provisioner:           rancher.io/local-path
Parameters:            <none>
AllowVolumeExpansion:  False
ReclaimPolicy:         Delete
VolumeBindingMode:     WaitForFirstConsumer

There is no namespace on a StorageClass. Every namespace in the cluster can reference lab-wffc by name.


Create a dynamically provisioned PVC

Work in a scratch namespace so cleanup is one delete:

bash
kubectl create namespace sc-lab
output
namespace/sc-lab created

Create a claim that names the class:

bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wffc-pvc
  namespace: sc-lab
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: lab-wffc
  resources:
    requests:
      storage: 256Mi
EOF

The claim is accepted immediately, but with WaitForFirstConsumer it stays Pending until a Pod mounts it. Create that consumer next:

bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: wffc-writer
  namespace: sc-lab
spec:
  containers:
  - name: app
    image: registry.k8s.io/e2e-test-images/agnhost:2.45
    command: ["sh", "-c", "echo hello-wffc > /data/out.txt; sleep 3600"]
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: wffc-pvc
EOF

When the Pod is Ready, list the claim and the generated volume:

bash
kubectl -n sc-lab get pvc,pv -o wide
output
NAME                             STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE   VOLUMEMODE
persistentvolumeclaim/wffc-pvc   Bound    pvc-c667f963-4943-473e-a2dd-5abf6d06b2ca   256Mi      RWO            lab-wffc       20s   Filesystem

NAME                                                        CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM             STORAGECLASS   AGE   VOLUMEMODE
persistentvolume/pvc-c667f963-4943-473e-a2dd-5abf6d06b2ca   256Mi      RWO            Delete           Bound    sc-lab/wffc-pvc   lab-wffc       2s    Filesystem

The PV name is generated (pvc-<uid>). Check how it was provisioned:

bash
kubectl get pv pvc-c667f963-4943-473e-a2dd-5abf6d06b2ca -o yaml

Trimmed fields from the lab:

text
annotations:
  pv.kubernetes.io/provisioned-by: rancher.io/local-path
  local.path.provisioner/selected-node: worker01
spec:
  capacity:
    storage: 256Mi
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: lab-wffc
  hostPath:
    path: /opt/local-path-provisioner/pvc-c667f963-4943-473e-a2dd-5abf6d06b2ca_sc-lab_wffc-pvc
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - worker01

The reclaim policy and storage class came from the StorageClass. The provisioner pinned the volume to worker01 because that is where the Pod was scheduled.

Confirm the mount is writable:

bash
kubectl -n sc-lab exec wffc-writer -- cat /data/out.txt
output
hello-wffc

That is the full dynamic path: StorageClass → PVC → provisioned PV → Pod mount. The next section isolates the Pending window before the Pod exists so you can recognize WaitForFirstConsumer on purpose.

Immediate vs WaitForFirstConsumer

volumeBindingMode controls when provisioning and binding start.

Immediate

With Immediate, the control plane tries to provision and bind as soon as the PVC exists, before any Pod is scheduled. That works well for many remote CSI backends where the volume is reachable from every node.

Node-local provisioners are different. Create an Immediate class and claim against local-path and the provisioner fails because no node has been selected yet:

output
Warning  ProvisioningFailed  ...  failed to provision volume with StorageClass "lab-immediate": configuration error, no node was specified

Pods that use that claim stay unschedulable with pod has unbound immediate PersistentVolumeClaims. For rancher.io/local-path, prefer WaitForFirstConsumer instead of Immediate.

WaitForFirstConsumer

WaitForFirstConsumer delays provisioning until a Pod references the claim. The scheduler can then honor node selectors, affinity, and taints together with storage topology.

If you create only the PVC (before any Pod), the phase stays Pending:

bash
kubectl -n sc-lab get pvc wffc-pvc
output
NAME       STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
wffc-pvc   Pending                                      lab-wffc       5s

Describe the claim and read the Events:

bash
kubectl -n sc-lab describe pvc wffc-pvc
output
Events:
  Type    Reason                Age   From                         Message
  ----    ------                ----  ----                         -------
  Normal  WaitForFirstConsumer  2s    persistentvolume-controller  waiting for first consumer to be created before binding

Pending here is expected, not a failed provisioner. After the consumer Pod exists, the claim binds, the PV appears with nodeAffinity for the Pod's node, and the Pod becomes Ready on that same node (worker01 in this lab). Topology and scheduling stay aligned.

Configure a default StorageClass

Mark a class as default with the annotation storageclass.kubernetes.io/is-default-class: "true". Patch the class you want as the cluster default:

bash
kubectl annotate storageclass lab-wffc storageclass.kubernetes.io/is-default-class=true --overwrite
bash
kubectl get storageclass
output
NAME                 PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
lab-immediate        rancher.io/local-path   Delete          Immediate              false                  2m52s
lab-retain           rancher.io/local-path   Retain          WaitForFirstConsumer   false                  2m51s
lab-wffc (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  2m52s
local-path           rancher.io/local-path   Delete          WaitForFirstConsumer   false                  4m3s

Create a PVC that omits storageClassName:

bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: default-pvc
  namespace: sc-lab
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 128Mi
EOF

Admission fills in the default class:

bash
kubectl -n sc-lab get pvc default-pvc
output
NAME          STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
default-pvc   Pending                                      lab-wffc       4s

The claim shows STORAGECLASS lab-wffc even though the YAML never set it. It stays Pending until a Pod consumes it because that class uses WaitForFirstConsumer.

Avoid multiple defaults. If several classes are marked default, Kubernetes uses the most recently created default for new PVCs without a class name.

To opt out of the default and request no class, set an empty string:

yaml
storageClassName: ""

That claim does not receive the default and stays unbound unless a matching static PV exists:

output
NAME           STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
no-class-pvc   Pending                                                     2s
output
Normal  FailedBinding  ...  no persistent volumes available for this claim and no storage class is set

Understand reclaim policy

reclaimPolicy on the StorageClass is copied to every dynamically provisioned PV.

Policy After you delete the PVC
Delete The PV is removed and the provisioner deletes supported backend data
Retain The PV moves to Released; data remains until an administrator cleans it up

Create a Retain class and a short-lived Pod that uses it, then delete the Pod and PVC:

bash
kubectl get pv pvc-667b7289-671a-468c-b109-de4980de687d \
  -o custom-columns=NAME:.metadata.name,PHASE:.status.phase,RECLAIM:.spec.persistentVolumeReclaimPolicy

After the PVC is gone:

output
NAME                                       PHASE      RECLAIM
pvc-667b7289-671a-468c-b109-de4980de687d   Released   Retain

The volume stays Released under Retain. With Delete (as on lab-wffc), the provisioner removes the PV after the claim is deleted. This article stops at observing the phase — reclaim and data recovery procedures belong with your storage runbooks and the static PV guide.


Enable volume expansion

Set allowVolumeExpansion: true on a StorageClass when your CSI driver supports online or offline resize:

yaml
allowVolumeExpansion: true

local-path reports expansion as unsupported on the stock class (ALLOWVOLUMEEXPANSION false). Even if you create a class with allowVolumeExpansion: true, patching the PVC size may only queue an expand that never finishes:

output
Normal  ExternalExpanding  ...  waiting for an external controller to expand this PVC

Capacity on the claim stayed at the original size in this lab. Treat allowVolumeExpansion as a gate plus a driver capability — both must be present. When resize is required in production, use a CSI driver that documents expansion support.


Troubleshooting

Symptom Likely cause Fix
PVC Pending, Events show provisioner name unknown StorageClass provisioner does not match an installed controller Fix the provisioner string or install the matching provisioner
PVC Pending, no default class Claim omitted storageClassName and no default exists Create or annotate a default StorageClass, or set storageClassName explicitly
PVC Pending, nonexistent class Typo in storageClassName Correct the name to a real StorageClass
PVC Pending with WaitForFirstConsumer No Pod consumes the claim yet Create a Pod that mounts the claim; do not treat this alone as failure
Immediate PVC fails with "no node was specified" Node-local provisioner needs a selected node Use WaitForFirstConsumer for local-path and similar backends
Unsupported access mode Provisioner cannot satisfy ReadWriteMany or similar Match access modes to what the backend supports
CSI or provisioner Pods not Ready Controller crashed or image pull failed Repair the provisioner Deployment before creating claims
Invalid parameters Provisioner-specific key rejected Remove unknown keys; follow the provisioner's parameter list
Expand stuck on ExternalExpanding Driver does not implement expansion Use a CSI driver that supports resize, or recreate a larger volume

For deeper Pending PVC and mount failures, continue with Fix Pending PVC and volume errors.


What's Next


References


Summary

You defined StorageClass objects against a live rancher.io/local-path provisioner, then watched a PVC select that class and receive a generated PersistentVolume. WaitForFirstConsumer kept the claim Pending until a Pod appeared, which is how node-local topology stays aligned with scheduling. Immediate mode was shown as the wrong choice for this provisioner because provisioning needs a selected node.

Default classes, empty storageClassName, and Retain versus Delete reclaim policies are cluster policy decisions that copy onto dynamic PVs. Volume expansion requires both the StorageClass flag and a capable driver — flipping the flag alone is not enough for local-path.

When claims stay Pending for the wrong reasons, check provisioner health and class names first, then follow the PVC troubleshooting guide. For static volumes without a provisioner, stay on the PersistentVolume article instead of inventing a StorageClass that nothing implements.


Frequently Asked Questions

1. Does creating a StorageClass by itself create disk space?

No. A StorageClass is a cluster-scoped policy object. Dynamic volumes appear only when a PVC selects that class and a matching provisioner is installed and able to create storage.

2. Why does my PVC stay Pending with WaitForFirstConsumer?

That is expected until a Pod references the claim. The controller waits for a consumer so the provisioner can place storage on a node that matches Pod scheduling. It is not the same as a provisioning failure.

3. What happens if I omit storageClassName on a PVC?

When a default StorageClass exists, admission assigns that class. If no default exists, the claim stays unbound unless a matching static PV is available. Setting storageClassName to an empty string opts out of the default and requests no class.

4. Can I mark more than one StorageClass as default?

You can, but you should avoid it. When several defaults exist, Kubernetes uses the most recently created default StorageClass for new PVCs that omit storageClassName.

5. Does allowVolumeExpansion on the StorageClass guarantee resize works?

No. The field must be true and the provisioner or CSI driver must implement expansion. Otherwise the PVC can remain Bound at the old capacity while waiting for an external expand controller that never completes the resize.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)