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:
| Mode | Short | Meaning |
|---|---|---|
ReadWriteOnce | RWO | One node can mount it read-write — most databases |
ReadOnlyMany | ROX | Many nodes can mount it read-only |
ReadWriteMany | RWX | Many 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
volumeClaimTemplatesto 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:
| Feature | Deployment | StatefulSet |
|---|---|---|
| Pod names | Random (e.g., myapp-7d6b4b5c8-4xkzp) | Stable ordered (e.g., postgres-0, postgres-1) |
| Start order | All at once | Ordered (0, then 1, then 2) |
| Storage | Shared or none | Each replica gets its own PVC |
| DNS | Single service DNS | Each 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-ebsStorageClass → creates EBS volumes - GCP:
standardStorageClass → creates GCE Persistent Disks - Azure:
managed-premiumStorageClass → 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