Kubernetes separates storage provisioning (creating storage volumes) from storage consumption (using volumes in pods) through two resource types: PersistentVolumes (PVs) and PersistentVolumeClaims (PVCs). A PersistentVolume is a piece of storage in the cluster provisioned by an administrator or dynamically by a StorageClass. A PersistentVolumeClaim is a request for storage by a user — it specifies the required capacity and access mode, and Kubernetes binds it to a matching PV. StorageClasses enable dynamic provisioning, where PVs are automatically created when a PVC is submitted, without requiring pre-created PVs. On RHEL 9 bare-metal clusters, the most common local storage solution is the local-path-provisioner (built into k3s) or manual local type PVs using NFS or hostPath for development.

Prerequisites

  • Kubernetes cluster running on RHEL 9

Step 1 — Install Local Path Provisioner (for kubeadm clusters)

# k3s includes local-path-provisioner by default
# For kubeadm clusters, install it manually:
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml

# Set as the default StorageClass
kubectl patch storageclass local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

# Verify
kubectl get storageclass

Step 2 — Create a PersistentVolumeClaim

# /tmp/postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  namespace: database
spec:
  accessModes:
    - ReadWriteOnce   # Mounted to a single node at a time
  resources:
    requests:
      storage: 20Gi
  storageClassName: local-path  # Or "standard" for cloud providers
kubectl create namespace database
kubectl apply -f /tmp/postgres-pvc.yaml
kubectl get pvc -n database

Step 3 — Use a PVC in a StatefulSet

# /tmp/postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: database
spec:
  selector:
    matchLabels:
      app: postgres
  serviceName: postgres
  replicas: 1
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres:16-alpine
        env:
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: pg-secret
              key: password
        ports:
        - containerPort: 5432
        volumeMounts:
        - name: postgres-data
          mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
  - metadata:
      name: postgres-data
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 20Gi

Step 4 — NFS StorageClass (for shared storage)

# Install NFS server on RHEL 9
dnf install -y nfs-utils
mkdir -p /srv/nfs/k8s
echo '/srv/nfs/k8s *(rw,sync,no_subtree_check,no_root_squash)' >> /etc/exports
systemctl enable --now nfs-server
exportfs -ra

# Install NFS subdir external provisioner via Helm
helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/
helm install nfs-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner 
    --set nfs.server=192.168.1.100 
    --set nfs.path=/srv/nfs/k8s 
    --set storageClass.name=nfs-client

Conclusion

Kubernetes persistent storage on RHEL 9 requires choosing the right StorageClass for your workload: local-path for single-node development clusters (data is node-local and not replicated), NFS for shared access across multiple pods or nodes, and cloud-provider storage classes for production cloud deployments. StatefulSets are the recommended Kubernetes resource for stateful workloads like databases — they provide stable pod identities, ordered deployment, and per-pod PVC templates that ensure each database replica has its own dedicated persistent volume.

Next steps: How to Deploy Applications to Kubernetes on RHEL 9, How to Set Up Kubernetes Ingress on RHEL 9, and How to Install k3s on RHEL 9.