Helm’s template command doesn’t actually create anything in Kubernetes; it just renders the Kubernetes manifest YAML.

Let’s see what Helm does with PersistentVolume (PV) and PersistentVolumeClaim (PVC) templates. Imagine you have a Helm chart for a web application that needs to store user uploads.

Here’s a simplified templates/pvc.yaml in your Helm chart:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:

  name: {{ include "mychart.fullname" . }}-data

  labels:

    {{- include "mychart.labels" . | nindent 4 }}

spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:

      storage: {{ .Values.storage.size }}


  storageClassName: {{ .Values.storage.className }}

And here’s values.yaml:

storage:
  size: 10Gi
  className: standard

When you run helm template myrelease ./mychart, Helm will process this. The {{ include "mychart.fullname" . }} will expand to something like myrelease-mychart, and {{ .Values.storage.size }} becomes 10Gi. The storageClassName will be standard. The output YAML will look like this:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: myrelease-mychart-data
  labels:
    app.kubernetes.io/name: mychart
    app.kubernetes.io/instance: myrelease
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  storageClassName: standard

This YAML is what would be sent to Kubernetes if you ran helm install. The critical thing to understand is that Helm itself doesn’t care about the lifecycle of PVs or PVCs. It just generates the YAML. The actual Kubernetes API server, upon receiving this YAML, will then create the PVC object.

The PersistentVolumeClaim object is a request for storage. Kubernetes, specifically the storage provisioner (if you’re using dynamic provisioning) or a manually created PersistentVolume object (if you’re using static provisioning), will fulfill this request.

If you’re using dynamic provisioning with a storageClassName like standard (which corresponds to a StorageClass defined in your cluster), Kubernetes will instruct the provisioner associated with that StorageClass to create a PersistentVolume. This new PV will then be bound to your PVC.

If you’re using static provisioning, you’d need to have already created a PersistentVolume object in your cluster with matching capacity, accessModes, and storageClassName. Kubernetes would then bind that pre-existing PV to your PVC.

The template’s job is simply to provide the specifications for the PVC. It can dynamically set the requested storage size, the access mode, and crucially, the storageClassName. This storageClassName is the key that tells Kubernetes how to provision the underlying storage.

Consider this: If your values.yaml had className: premium, and you had a premium StorageClass configured, Helm would render a PVC requesting that premium storage.

# values.yaml
storage:
  size: 50Gi
  className: premium

The rendered PVC would then be:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: myrelease-mychart-data
  labels:
    app.kubernetes.io/name: mychart
    app.kubernetes.io/instance: myrelease
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 50Gi
  storageClassName: premium

The real magic happens after Helm renders the YAML. Kubernetes takes over. The storageClassName in the PVC is the pointer to the StorageClass resource in Kubernetes. This StorageClass resource contains information about the provisioner (e.g., kubernetes.io/aws-ebs, kubernetes.io/gce-pd, or a CSI driver like ebs.csi.aws.com) and its parameters (like volume type, IOPS, etc.).

The one thing most people don’t realize is that the storageClassName in the PVC template must exist as a StorageClass resource in the Kubernetes cluster at the time the PVC is created. If it doesn’t, the PVC will remain in a Pending state indefinitely, waiting for a matching StorageClass or a manually created PV.

The next thing you’ll likely grapple with is how to manage the lifecycle of these dynamically provisioned volumes, especially when you helm uninstall a release. By default, the Delete reclaim policy on the StorageClass will remove the underlying storage.

Want structured learning?

Take the full Helm course →