# 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:

```csharp title="AppHost.cs"
var k8s = builder.AddKubernetesEnvironment("k8s");

var pgData = k8s.AddPersistentVolume("pg-data")
    .WithStorageClass("managed-csi")
    .WithCapacity("20Gi");
```

**Note:** The persistent volume APIs are experimental. In C#, suppress the `ASPIRECOMPUTE002` diagnostic to use them.

```typescript title="apphost.mts"
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](/deployment/kubernetes-ingress/), you configure the volume once and reference it from workloads, rather than relying on a single environment-wide storage shape for every mount.

## Prerequisites

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

- The [Aspire.Hosting.Kubernetes](/integrations/compute/kubernetes/) hosting integration installed in your AppHost.
- A [Kubernetes environment](/integrations/compute/kubernetes/#add-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

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:

```csharp title="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();
```

```typescript title="apphost.mts"
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

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

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:

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

builder.AddPostgres("pg")
    .WithDataVolume("pg-data")
    .WithPersistentVolume(pgData);
```

```typescript title="apphost.mts"
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

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:

```csharp title="AppHost.cs"
var media = k8s.AddPersistentVolume("media")
    .WithStorageClass("azurefile-csi")
    .WithCapacity("100Gi")
    .WithAccessMode(PersistentVolumeAccessMode.ReadWriteMany);

builder.AddProject<Projects.Api>("api")
    .WithPersistentVolume(media, "/srv/media");
```

```typescript title="apphost.mts"
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');
```

**Workloads are promoted to StatefulSet:** Any workload bound to a persistent volume is rendered as a `StatefulSet` instead of a `Deployment`, regardless of its default workload kind. There is no opt-out. A `Deployment` with a `ReadWriteOnce` claim and more than one replica is broken by design because the second pod can't mount the same volume. Therefore, binding to a durable volume automatically selects the `StatefulSet` path, which gives pods stable identity and ordered rollout.

## 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`.

**Aspire version:** The automatic security context described in this section requires Aspire 13.6 or later.

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:

```yaml
securityContext:
  fsGroup: 2000
  fsGroupChangePolicy: OnRootMismatch
```

`fsGroup` 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

The default security context is applied before any `PublishAsKubernetesService` callback runs, so a workload or cluster that needs a different group can replace it:

```csharp title="AppHost.cs"
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:

```csharp title="AppHost.cs"
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.

**Caution:** The group ID `2000` is an Aspire-managed default, not a Kubernetes-reserved value. Some storage drivers don't support `fsGroup`, some CSI drivers apply the group at mount time themselves, and cluster admission policies can restrict allowed group ranges. Use the customization callback above as an escape hatch in those environments.

## 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

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

```yaml title="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"
      securityContext:
        fsGroup: 2000
        fsGroupChangePolicy: "OnRootMismatch"
  replicas: 1
```

```yaml title="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"
```

<LearnMore>
  To generate and inspect the Helm chart for yourself, see [Deploy to Kubernetes clusters](/deployment/kubernetes/clusters/).
</LearnMore>

## Persistent volumes on AKS

`AddPersistentVolume` is also available directly on an [Azure Kubernetes Service (AKS) environment](/integrations/cloud/azure/aks/#add-a-persistent-volume), 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.

## See also

- [Kubernetes integration](/integrations/compute/kubernetes/)
- [Deploy to Kubernetes clusters](/deployment/kubernetes/clusters/)
- [Deploy to AKS](/deployment/kubernetes/aks/)
- [Azure Kubernetes Service (AKS) integration](/integrations/cloud/azure/aks/)
- [Persist data with volumes](/fundamentals/persist-data-volumes/)
- [Kubernetes persistent volumes documentation](https://kubernetes.io/docs/concepts/storage/persistent-volumes/)