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:
var k8s = builder.AddKubernetesEnvironment("k8s");
var pgData = k8s.AddPersistentVolume("pg-data") .WithStorageClass("managed-csi") .WithCapacity("20Gi");const k8s = await builder.addKubernetesEnvironment('k8s');
const pgData = await k8s.addPersistentVolume('pg-data');await pgData.withStorageClass('managed-csi');await pgData.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.
Prerequisites
Section titled “Prerequisites”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.
Add a persistent volume
Section titled “Add a persistent volume”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:
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();import { createBuilder } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const k8s = await builder.addKubernetesEnvironment('k8s');
const pgData = await k8s.addPersistentVolume('pg-data');await pgData.withStorageClass('managed-csi');await pgData.withCapacity('20Gi');await pgData.withVolumeAnnotation('disk.csi.azure.com/skuName', 'Premium_LRS');
await 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# | TypeScript | Description |
|---|---|---|
WithStorageClass(string) | withStorageClass / withStorageClassParam | Sets 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 withStorageClassParam for a parameter. |
WithCapacity(string) | withCapacity / withCapacityParam | Sets the requested storage on spec.resources.requests.storage (for example, "20Gi"). In TypeScript, use withCapacity for a literal and withCapacityParam for a parameter. |
WithAccessMode(PersistentVolumeAccessMode) | withAccessMode | Adds an entry to spec.accessModes. Call multiple times to declare more than one mode. |
WithVolumeAnnotation(string, string) | withVolumeAnnotation / withVolumeAnnotationParam | Adds 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 mode | Description |
|---|---|
ReadWriteOnce | Mounted as read-write by a single node. Most common for block-storage-backed databases. |
ReadOnlyMany | Mounted as read-only by many nodes simultaneously. |
ReadWriteMany | Mounted as read-write by many nodes simultaneously. Typically used for shared file stores such as Azure Files or NFS. |
ReadWriteOncePod | Mounted as read-write by a single pod. Requires Kubernetes 1.27 or later. |
Bind a volume to a workload
Section titled “Bind a volume to a workload”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.
Bind by name
Section titled “Bind by name”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:
var pgData = k8s.AddPersistentVolume("pg-data") .WithStorageClass("managed-csi") .WithCapacity("20Gi");
builder.AddPostgres("pg") .WithDataVolume("pg-data") .WithPersistentVolume(pgData);const pgData = await k8s.addPersistentVolume('pg-data');await pgData.withStorageClass('managed-csi');await pgData.withCapacity('20Gi');
const pg = await builder.addPostgres('pg');await pg.withDataVolume({ name: 'pg-data' });await pg.withKubernetesPersistentVolume(pgData);Bind with a mount path
Section titled “Bind with a mount path”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:
var media = k8s.AddPersistentVolume("media") .WithStorageClass("azurefile-csi") .WithCapacity("100Gi") .WithAccessMode(PersistentVolumeAccessMode.ReadWriteMany);
builder.AddProject<Projects.Api>("api") .WithPersistentVolume(media, "/srv/media");const media = await k8s.addPersistentVolume('media');await media.withStorageClass('azurefile-csi');await media.withCapacity('100Gi');
const api = await builder.addProject('api');await api.withKubernetesPersistentVolumeMount(media, '/srv/media');Default pod security context
Section titled “Default pod security context”A Kubernetes access mode such as ReadWriteOnce controls how a volume can be attached and mounted; it doesn’t grant the container’s Linux process permission to write to the mounted filesystem. Without further configuration, a freshly provisioned volume is typically owned by root:root, so a workload that runs as a non-root user fails on its first write even though the PVC is Bound and the pod is Running.
To avoid that out-of-the-box failure, both WithPersistentVolume(...) overloads — and their TypeScript equivalents, withKubernetesPersistentVolume and withKubernetesPersistentVolumeMount — add the following pod security context to the workload automatically:
securityContext: fsGroup: 2000 fsGroupChangePolicy: OnRootMismatchfsGroup adds a supplemental group to the pod’s processes and, for supported volume types, instructs Kubernetes or the CSI driver to make the mounted volume accessible to that group. It doesn’t change the image-defined UID or primary GID, so Aspire doesn’t need to know which identity the image uses. Aspire always uses the same group ID, 2000, rather than choosing a new value on each deployment, because group ownership is persisted on the volume. OnRootMismatch avoids an unnecessary recursive ownership change when the volume already has the expected group.
This default only applies to workloads bound through WithPersistentVolume(...). Ordinary workloads and legacy PVC generation through the Kubernetes environment’s default storage type are unaffected, and read-only mounts remain read-only.
Overriding or removing the default
Section titled “Overriding or removing the default”The default security context is applied before any PublishAsKubernetesService callback runs, so a workload or cluster that needs a different group can replace it:
using Aspire.Hosting.Kubernetes.Resources;
builder.AddProject<Projects.Api>("api") .WithPersistentVolume(media, "/srv/media") .PublishAsKubernetesService(resource => { var statefulSet = (StatefulSet)resource.Workload!; var podSpec = statefulSet.Spec.Template.Spec;
podSpec.SecurityContext ??= new(); podSpec.SecurityContext.FsGroup = 3000; });The TypeScript AppHost API supports publishAsKubernetesService, but its KubernetesResource callback doesn’t currently expose the generated workload or pod spec. It can’t override the generated fsGroup from the AppHost.
The generated OnRootMismatch change policy is retained unless the callback also replaces it.
To remove the generated pod security context entirely — for example, when ownership is managed by the image, an admission controller, or storage-specific configuration — set it to null in the same callback:
using Aspire.Hosting.Kubernetes.Resources;
builder.AddProject<Projects.Api>("api") .WithPersistentVolume(media, "/srv/media") .PublishAsKubernetesService(resource => { var statefulSet = (StatefulSet)resource.Workload!; statefulSet.Spec.Template.Spec.SecurityContext = null; });The TypeScript AppHost API supports publishAsKubernetesService, but its KubernetesResource callback doesn’t currently expose the generated workload or pod spec. It can’t remove the generated pod security context from the AppHost.
Unbound volumes and default storage
Section titled “Unbound volumes and default storage”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.
Generated output
Section titled “Generated output”Binding a container to a persistent volume by name produces a StatefulSet whose pod spec references the generated claim, alongside the PersistentVolumeClaim itself:
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" securityContext: fsGroup: 2000 fsGroupChangePolicy: "OnRootMismatch" replicas: 1apiVersion: "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.
Persistent volumes on AKS
Section titled “Persistent volumes on AKS”AddPersistentVolume is also available directly on an Azure Kubernetes Service (AKS) environment, so you don’t need to reach through to the underlying Kubernetes environment when targeting AKS. The AKS overload forwards to the same publisher described on this page, and uses the same configuration and binding APIs.