Pods are ephemeral. When a Pod is deleted or rescheduled to a different node, all data written inside it is gone.

This is fine for stateless apps. But databases, file uploads, and cache data need to survive Pod restarts. That is what Persistent Volumes are for.

The Problem with Pod Storage

By default, a container’s filesystem lives only as long as the container lives. When the container stops, the data disappears.

Pod starts   container writes data to /var/lib/postgresql
Pod crashes  data is gone
Pod restarts  database starts with empty storage

To fix this, Kubernetes has a three-layer storage system: PersistentVolumes, PersistentVolumeClaims, and StorageClasses.

The Three Storage Concepts

PersistentVolume (PV)

A PersistentVolume is a piece of storage that exists independently of any Pod. It could be a disk on a cloud provider, a network filesystem, or a local directory.

Think of a PV as a disk that exists in the cluster, waiting to be claimed.

PersistentVolumeClaim (PVC)

A PersistentVolumeClaim is a request for storage by a Pod. It specifies how much storage is needed and what access mode is required.

Think of a PVC as a ticket: “I need 5 GB of storage that I can read and write.”

StorageClass

A StorageClass defines how storage is dynamically created. Instead of pre-creating PVs manually, a StorageClass automatically provisions them when a PVC is created.

StorageClass → defines HOW to create storage
PVC → requests WHAT storage is needed
PV → the ACTUAL storage that was created
Pod → uses the PVC to access the PV

Access Modes

When you create a PVC, you specify an access mode:

ModeShortMeaning
ReadWriteOnceRWOOne node can mount it read-write — most databases
ReadOnlyManyROXMany nodes can mount it read-only
ReadWriteManyRWXMany nodes can mount it read-write — shared filesystems

Most databases use ReadWriteOnce. Only specialized storage backends support ReadWriteMany.

Static vs Dynamic Provisioning

Static Provisioning

An admin creates PVs manually. Then Pods claim them with PVCs.

# pv-static.yaml — manually created PersistentVolume
apiVersion: v1
kind: PersistentVolume
metadata:
  name: my-pv
spec:
  capacity:
    storage: 5Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  hostPath:
    path: /data/myapp   # Only for local/dev use — NOT for production

Static provisioning is rare today. Most clusters use dynamic provisioning.

Dynamic Provisioning with StorageClass

With a StorageClass, you do not need to create PVs manually. Just create a PVC, and the StorageClass provisions a PV automatically.

minikube comes with a default StorageClass:

kubectl get storageclasses
NAME                 PROVISIONER                RECLAIMPOLICY   VOLUMEBINDINGMODE
standard (default)   k8s.io/minikube-hostpath   Delete          Immediate

Deploying PostgreSQL with Persistent Storage

Here is a complete example: PostgreSQL running with a PVC.

Step 1: Create a Secret for the Database Password

Note on PVCs with StatefulSets: You do not need to create the PVC manually. StatefulSets use volumeClaimTemplates to automatically create a PVC for each replica (e.g., postgres-data-postgres-0). The manual PVC step is only needed if you are deploying with a standard Deployment.

# postgres-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: postgres-secret
type: Opaque
stringData:
  POSTGRES_PASSWORD: "securepassword123"
  POSTGRES_USER: "appuser"
  POSTGRES_DB: "myappdb"

Step 2: Create a StorageClass (if needed)

Most cloud providers have a default StorageClass. Check with:

kubectl get storageclass

If you are using minikube or kind, the default standard StorageClass supports dynamic provisioning.

Step 3: Deploy PostgreSQL as a StatefulSet

For databases, use a StatefulSet instead of a Deployment. We explain why below.

# postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: "postgres"
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
          envFrom:
            - secretRef:
                name: postgres-secret
          volumeMounts:
            - name: postgres-data
              mountPath: /var/lib/postgresql/data
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
  volumeClaimTemplates:               # StatefulSet manages PVCs per replica
    - metadata:
        name: postgres-data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 5Gi

Step 4: Create a Headless Service for PostgreSQL

StatefulSets require a headless service (clusterIP: None) to give each Pod a stable DNS name. This enables discovery like postgres-0.postgres within the cluster.

# postgres-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres          # Must match serviceName in the StatefulSet
spec:
  selector:
    app: postgres
  clusterIP: None         # Headless — enables stable pod DNS (postgres-0.postgres)
  ports:
    - port: 5432
      targetPort: 5432

Apply everything:

kubectl apply -f postgres-secret.yaml -f postgres-service.yaml -f postgres-statefulset.yaml
kubectl get pods
kubectl get pvc
NAME          READY   STATUS    RESTARTS   AGE
postgres-0    1/1     Running   0          30s

NAME                        STATUS   VOLUME     CAPACITY   ACCESS MODES
postgres-data-postgres-0    Bound    pvc-...    5Gi        RWO

The data now persists. Delete and recreate the Pod — the data survives.

StatefulSets vs Deployments

Use Deployments for:

  • Stateless apps (web servers, APIs)
  • Apps where any replica is identical and interchangeable
  • Apps that do not need stable network names

Use StatefulSets for:

  • Databases (PostgreSQL, MySQL, MongoDB)
  • Apps that need stable, persistent network identifiers
  • Apps where each replica has unique state

Key differences:

FeatureDeploymentStatefulSet
Pod namesRandom (e.g., myapp-7d6b4b5c8-4xkzp)Stable ordered (e.g., postgres-0, postgres-1)
Start orderAll at onceOrdered (0, then 1, then 2)
StorageShared or noneEach replica gets its own PVC
DNSSingle service DNSEach Pod gets its own DNS entry

Volume Types

emptyDir — Temporary Storage

Created when a Pod starts, deleted when the Pod stops. Use for temporary files, caches, or sharing data between containers in the same Pod.

volumes:
  - name: temp-cache
    emptyDir: {}

hostPath — Node Directory (Dev Only)

Mounts a directory from the host node. Do not use in production — it ties your Pod to a specific node and breaks if the Pod moves.

volumes:
  - name: host-data
    hostPath:
      path: /data/myapp
      type: DirectoryOrCreate

Cloud Volumes (Production)

In production, use cloud-native storage:

  • AWS: aws-ebs StorageClass → creates EBS volumes
  • GCP: standard StorageClass → creates GCE Persistent Disks
  • Azure: managed-premium StorageClass → creates Azure Managed Disks

These are automatically created and destroyed as PVCs are created and deleted.

Reclaim Policy

When a PVC is deleted, what happens to the PV?

  • Delete — the PV and its underlying storage are deleted (default for dynamic provisioning)
  • Retain — the PV is kept, even after the PVC is deleted (manual cleanup required)
  • Recycle — deprecated, do not use
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: slow
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain      # Keep data even after PVC deletion
volumeBindingMode: WaitForFirstConsumer

For production databases, use Retain so you do not accidentally delete your data.

Resizing Volumes

If you need more storage, you can resize a PVC:

# Edit the PVC and increase the storage request
kubectl edit pvc postgres-pvc

Change storage: 5Gi to storage: 10Gi. Kubernetes will expand the underlying volume if the StorageClass supports it.

In K8s 1.35, in-place Pod resize (GA) also allows resizing CPU and memory without restarting containers. Combined with PVC expansion, you can scale a stateful workload without downtime.

Backup Considerations

PVs do not back themselves up. If you delete the PV, or the underlying disk fails, the data is gone.

For production, use Velero — an open-source backup tool for Kubernetes. It backs up:

  • All cluster resources (Deployments, Services, ConfigMaps)
  • PersistentVolume data via volume snapshots

We cover backups in Kubernetes Tutorial #12: Production Checklist.

Common Mistakes

Using Deployments for databases

Databases need stable Pod names and unique storage per replica. A Deployment gives neither. Use StatefulSets for databases.

Using hostPath in production

hostPath binds a Pod to a specific node. If the Pod is rescheduled to another node, the data is gone. Use cloud volumes or a distributed storage system.

Forgetting the reclaim policy

If you delete a PVC with the default Delete reclaim policy, the underlying storage is deleted too. Set Retain for any PVC holding important data.

What’s Next?

Your app can store data persistently. The next step is routing external traffic to it. In 2026, that means the Kubernetes Gateway API.

Next: Kubernetes Tutorial #6: Ingress and Gateway API