Skip to content
DocsTry Aspire
DocsTry

Persistent volumes on Kubernetes

Stateful workloads — databases, message brokers, or any service that writes durable data — need storage that survives pod restarts and rescheduling. Call AddPersistentVolume on a Kubernetes environment to describe a durable disk once in your AppHost, configure its storage class, capacity, access modes, and annotations with fluent methods, then bind it to one or more workloads:

AppHost.cs
var k8s = builder.AddKubernetesEnvironment("k8s");
var pgData = k8s.AddPersistentVolume("pg-data")
.WithStorageClass("managed-csi")
.WithCapacity("20Gi");

At publish time, the volume renders as a v1.PersistentVolumeClaim in the generated Helm chart. Like ingress and gateway resources, you configure the volume once and reference it from workloads, rather than relying on a single environment-wide storage shape for every mount.

The following prerequisites must be in place before you can use persistent volumes in a Kubernetes cluster:

  • The Aspire.Hosting.Kubernetes hosting integration installed in your AppHost.
  • A Kubernetes environment added to your AppHost.
  • A cluster with a storage class that can provision the volumes you request. Most managed clusters ship a default storage class.

The full set of configuration methods lets you pin the storage class, capacity, access modes, and provisioner annotations. A complete AppHost that defines a volume looks like this:

AppHost.cs
using Aspire.Hosting.Kubernetes;
var builder = DistributedApplication.CreateBuilder(args);
var k8s = builder.AddKubernetesEnvironment("k8s");
var pgData = k8s.AddPersistentVolume("pg-data")
.WithStorageClass("managed-csi")
.WithCapacity("20Gi")
.WithAccessMode(PersistentVolumeAccessMode.ReadWriteOnce)
.WithVolumeAnnotation("disk.csi.azure.com/skuName", "Premium_LRS");
builder.Build().Run();

Every configuration method is optional. When you don’t set a storage class, the cluster’s default storage class provisions the backing disk. When you don’t set a capacity or access mode, the environment’s default storage size and read-write policy apply.

The available configuration methods are:

C#TypeScriptDescription
WithStorageClass(string)withStorageClass / withPvStorageClassParamSets spec.storageClassName on the PVC. In C#, the single method accepts a literal string or an Aspire parameter; in TypeScript, use withStorageClass for a literal and withPvStorageClassParam for a parameter.
WithCapacity(string)withCapacity / withPvCapacityParamSets the requested storage on spec.resources.requests.storage (for example, "20Gi"). In TypeScript, use withCapacity for a literal and withPvCapacityParam for a parameter.
WithAccessMode(PersistentVolumeAccessMode)withAccessModeAdds an entry to spec.accessModes. Call multiple times to declare more than one mode.
WithVolumeAnnotation(string, string)withVolumeAnnotation / withVolumeAnnotationParamAdds a key-value pair to the PVC’s metadata.annotations. Useful for CSI driver hints, dynamic provisioner parameters, or backup tooling tags. In TypeScript, use withVolumeAnnotation for a literal value and withVolumeAnnotationParam for a parameter.

The PersistentVolumeAccessMode values map directly to the Kubernetes access modes:

Access modeDescription
ReadWriteOnceMounted as read-write by a single node. Most common for block-storage-backed databases.
ReadOnlyManyMounted as read-only by many nodes simultaneously.
ReadWriteManyMounted as read-write by many nodes simultaneously. Typically used for shared file stores such as Azure Files or NFS.
ReadWriteOncePodMounted as read-write by a single pod. Requires Kubernetes 1.27 or later.

After defining a volume, bind it to the workloads that mount it. There are two overloads, depending on whether the workload already declares a named volume.

Use the name-match overload when the workload already declares a volume — for example, through WithVolume("name", "/path") or an integration helper such as Postgres’ WithDataVolume(). The persistent volume’s name must match the workload volume’s name so the publisher can route the pod’s volumes[] entry through the generated PVC:

AppHost.cs
var pgData = k8s.AddPersistentVolume("pg-data")
.WithStorageClass("managed-csi")
.WithCapacity("20Gi");
builder.AddPostgres("pg")
.WithDataVolume("pg-data")
.WithPersistentVolume(pgData);

Use the mount-path overload when the workload doesn’t already declare a named volume. It creates the mount itself, so it works for projects and any compute resource. Pass the mount path inside the container, and optionally mount read-only:

AppHost.cs
var media = k8s.AddPersistentVolume("media")
.WithStorageClass("azurefile-csi")
.WithCapacity("100Gi")
.WithAccessMode(PersistentVolumeAccessMode.ReadWriteMany);
builder.AddProject<Projects.Api>("api")
.WithPersistentVolume(media, "/srv/media");

Volumes on a workload that aren’t bound to a KubernetesPersistentVolumeResource continue to use the environment’s default storage type. You can mix a first-class persistent volume and an unbound ephemeral volume on the same workload — each is resolved independently at publish time.

Binding a container to a persistent volume by name produces a StatefulSet whose pod spec references the generated claim, alongside the PersistentVolumeClaim itself:

StatefulSet (excerpt)
apiVersion: "apps/v1"
kind: "StatefulSet"
metadata:
name: "service-statefulset"
spec:
template:
spec:
containers:
- image: "nginx:latest"
name: "service"
volumeMounts:
- name: "data"
mountPath: "/var/lib/data"
volumes:
- name: "data"
persistentVolumeClaim:
claimName: "data"
replicas: 1
PersistentVolumeClaim
apiVersion: "v1"
kind: "PersistentVolumeClaim"
metadata:
name: "data"
annotations:
volume.beta.kubernetes.io/storage-provisioner: "disk.csi.azure.com"
spec:
storageClassName: "managed-csi"
accessModes:
- "ReadWriteOnce"
resources:
requests:
storage: "20Gi"

To generate and inspect the Helm chart for yourself, see Deploy to Kubernetes clusters.