Imagine you're staying at a hotel. Every time you check out, housekeeping completely resets the room. Your notes, belongings, everything is gone.
Now imagine if the hotel had lockers in the basement where you could store things that survive between stays. That's persistent storage.
In Kubernetes:
- Pods are hotel rooms - When they die, everything inside is gone
- Persistent Volumes are the lockers - Storage that survives pod restarts
- Persistent Volume Claims are your locker key - Request for storage
By default, everything in a container is ephemeral (temporary).
What happens when pod restarts:
# Create a pod
kubectl run temp-pod --image=busybox:1.36 -- sh -c "echo 'Important data' > /tmp/file.txt && sleep 3600"
# Check the file exists
kubectl exec temp-pod -- cat /tmp/file.txt
# Output: Important data
# Delete and recreate pod
kubectl delete pod temp-pod
kubectl run temp-pod --image=busybox:1.36 -- sh -c "cat /tmp/file.txt 2>/dev/null || echo 'File is gone'"
# Check again
kubectl logs temp-pod
# Output: File is goneThe problem: Databases, file uploads, logs - all gone when pod restarts.
The solution: Persistent storage that exists independently of pod lifecycle.
There are 3 main storage concepts you need to understand:
Storage that lives as long as the pod lives. Shared between containers in the pod.
Types:
emptyDir- Empty directory created when pod startshostPath- Mount directory from node's filesystemconfigMap/secret- Mount config as files (you saw this in Chapter 5)
Cluster-level storage resource. Exists independently of pods.
Think of it as: Physical storage available in the cluster.
Request for storage by a user. Pods use PVCs to get storage.
Think of it as: Storage reservation ticket.
1. Admin creates PV (physical storage)
↓
2. User creates PVC (requests storage)
↓
3. Kubernetes binds PVC to PV (matches request)
↓
4. Pod uses PVC (mounts storage)
In minikube, PVs are auto-created for you. In real clusters, admins provision them.
emptyDir is a directory that starts empty when pod is created. All containers in the pod can access it.
Use case: Share data between containers in same pod (like logs, cache).
Create pod with emptyDir:
# File: pod-with-emptydir.yaml
apiVersion: v1
kind: Pod
metadata:
name: shared-storage-pod
spec:
containers:
# Writer container
- name: writer
image: busybox:1.36
command: ['sh', '-c']
args:
- |
while true; do
echo "$(date): Writer says hello" >> /data/log.txt
sleep 5
done
volumeMounts:
- name: shared-data
mountPath: /data
# Reader container
- name: reader
image: busybox:1.36
command: ['sh', '-c']
args:
- |
while true; do
echo "=== Latest logs ==="
tail -5 /data/log.txt 2>/dev/null || echo "No logs yet"
sleep 10
done
volumeMounts:
- name: shared-data
mountPath: /data
volumes:
- name: shared-data
emptyDir: {}kubectl apply -f pod-with-emptydir.yaml
# Check writer logs
kubectl logs shared-storage-pod -c writer
# Writes to /data/log.txt
# Check reader logs
kubectl logs shared-storage-pod -c reader
# Reads from /data/log.txt
# Both containers see the same file!The breakdown:
volumessection defines the emptyDir volume- Both containers mount it to
/data - Data survives individual container crashes
- Data is DELETED when pod is deleted
Try it:
# Delete and recreate pod
kubectl delete pod shared-storage-pod
kubectl apply -f pod-with-emptydir.yaml
# Check logs
kubectl logs shared-storage-pod -c reader
# Output: No logs yet (data is gone)hostPath mounts a directory from the node's filesystem into the pod.
Warning: Only works if pod is scheduled on same node. Not portable. Use with caution.
# File: pod-with-hostpath.yaml
apiVersion: v1
kind: Pod
metadata:
name: hostpath-pod
spec:
containers:
- name: app
image: busybox:1.36
command: ['sh', '-c']
args:
- |
echo "Writing to node filesystem"
echo "$(date): Pod was here" >> /host-data/visits.txt
cat /host-data/visits.txt
sleep 3600
volumeMounts:
- name: host-storage
mountPath: /host-data
volumes:
- name: host-storage
hostPath:
path: /tmp/k8s-data
type: DirectoryOrCreate # Creates dir if not existskubectl apply -f pod-with-hostpath.yaml
# Check logs
kubectl logs hostpath-pod
# Shows data written to /tmp/k8s-data on node
# Delete pod
kubectl delete pod hostpath-pod
# Recreate
kubectl apply -f pod-with-hostpath.yaml
# Check logs again
kubectl logs hostpath-pod
# Old data still there! (if scheduled on same node)When to use hostPath:
- Accessing node's Docker socket (
/var/run/docker.sock) - Reading node logs
- Development/testing on single-node clusters
When NOT to use:
- Production apps (not portable)
- Multi-node clusters (data is node-specific)
Now the real stuff. PVCs are how you get storage that truly survives.
# File: pvc-demo.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: my-pvc
spec:
accessModes:
- ReadWriteOnce # Can be mounted by single node
resources:
requests:
storage: 1Gi # Request 1GB storage
storageClassName: standard # Storage class (auto in minikube)kubectl apply -f pvc-demo.yaml
# Check PVC status
kubectl get pvc
# NAME STATUS VOLUME CAPACITY ACCESS MODES
# my-pvc Bound pvc-abc123... 1Gi RWO
# Check PV (auto-created in minikube)
kubectl get pv
# Shows the PV that was automatically provisionedAccess Modes explained:
ReadWriteOnce(RWO) - Mount by single node (most common)ReadOnlyMany(ROX) - Mount by multiple nodes, read-onlyReadWriteMany(RWX) - Mount by multiple nodes, read-write
# File: pod-with-pvc.yaml
apiVersion: v1
kind: Pod
metadata:
name: pvc-pod
spec:
containers:
- name: app
image: busybox:1.36
command: ['sh', '-c']
args:
- |
# Check if data file exists
if [ -f /data/counter.txt ]; then
COUNTER=$(cat /data/counter.txt)
echo "Previous counter value: $COUNTER"
COUNTER=$((COUNTER + 1))
else
echo "First run, initializing counter"
COUNTER=1
fi
echo $COUNTER > /data/counter.txt
echo "Current counter: $COUNTER"
echo "Sleeping for 1 hour..."
sleep 3600
volumeMounts:
- name: persistent-storage
mountPath: /data
volumes:
- name: persistent-storage
persistentVolumeClaim:
claimName: my-pvckubectl apply -f pod-with-pvc.yaml
# Check logs
kubectl logs pvc-pod
# Output: First run, initializing counter
# Current counter: 1
# Delete pod
kubectl delete pod pvc-pod
# Recreate pod
kubectl apply -f pod-with-pvc.yaml
# Check logs again
kubectl logs pvc-pod
# Output: Previous counter value: 1
# Current counter: 2
# Data survived! PVC still exists.The magic: PVC exists independently. Pod can be deleted and recreated, data remains.
# Delete pod multiple times
kubectl delete pod pvc-pod
kubectl apply -f pod-with-pvc.yaml
kubectl logs pvc-pod
# Counter: 3
kubectl delete pod pvc-pod
kubectl apply -f pod-with-pvc.yaml
kubectl logs pvc-pod
# Counter: 4
# PVC keeps data across pod restarts!Real-world example: MySQL database with persistent data.
# File: mysql-with-storage.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: Pod
metadata:
name: mysql
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0
env:
- name: MYSQL_ROOT_PASSWORD
value: "password123"
- name: MYSQL_DATABASE
value: "testdb"
ports:
- containerPort: 3306
volumeMounts:
- name: mysql-storage
mountPath: /var/lib/mysql # MySQL data directory
volumes:
- name: mysql-storage
persistentVolumeClaim:
claimName: mysql-pvckubectl apply -f mysql-with-storage.yaml
# Wait for MySQL to be ready
kubectl wait --for=condition=ready pod/mysql --timeout=60s
# Create some data
kubectl exec -it mysql -- mysql -uroot -ppassword123 testdb -e "
CREATE TABLE users (id INT, name VARCHAR(50));
INSERT INTO users VALUES (1, 'Alice'), (2, 'Bob');
SELECT * FROM users;
"
# Output:
# +------+-------+
# | id | name |
# +------+-------+
# | 1 | Alice |
# | 2 | Bob |
# +------+-------+
# Delete pod
kubectl delete pod mysql
# Recreate
kubectl apply -f mysql-with-storage.yaml
kubectl wait --for=condition=ready pod/mysql --timeout=60s
# Check data still exists
kubectl exec -it mysql -- mysql -uroot -ppassword123 testdb -e "SELECT * FROM users;"
# Data is still there!StorageClass defines different "types" of storage with different characteristics.
# Check available storage classes
kubectl get storageclass
# In minikube:
# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
# standard (default) k8s.io/minikube-hostpath Delete ImmediateWhat they control:
- Provisioner - What creates the storage (AWS EBS, GCP Disk, local, etc.)
- Reclaim Policy - What happens to PV when PVC is deleted
Delete- PV is deleted (data gone)Retain- PV kept for manual cleanup
- Volume Binding Mode - When PV is created
Immediate- Created when PVC is createdWaitForFirstConsumer- Created when pod uses it
Create PVC with specific storage class:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fast-storage
spec:
accessModes:
- ReadWriteOnce
storageClassName: fast-ssd # Specific storage class
resources:
requests:
storage: 5GiFor databases and stateful apps, you use StatefulSet instead of Deployment.
StatefulSet provides:
- Stable pod names (pod-0, pod-1, pod-2)
- Ordered deployment and scaling
- Automatic PVC creation per pod
# File: statefulset-demo.yaml
apiVersion: v1
kind: Service
metadata:
name: nginx-headless
spec:
clusterIP: None # Headless service
selector:
app: nginx-stateful
ports:
- port: 80
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: nginx-headless
replicas: 3
selector:
matchLabels:
app: nginx-stateful
template:
metadata:
labels:
app: nginx-stateful
spec:
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
command: ['sh', '-c']
args:
- |
# Write pod name to index.html
echo "<h1>Hello from $HOSTNAME</h1>" > /usr/share/nginx/html/index.html
nginx -g 'daemon off;'
# VolumeClaimTemplate creates PVC per pod
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gikubectl apply -f statefulset-demo.yaml
# Watch pods being created in order
kubectl get pods -w
# web-0 0/1 Creating
# web-0 1/1 Running
# web-1 0/1 Creating # Only starts after web-0 is ready
# web-1 1/1 Running
# web-2 0/1 Creating
# web-2 1/1 Running
# Check PVCs (one per pod)
kubectl get pvc
# www-web-0 Bound pvc-abc... 1Gi
# www-web-1 Bound pvc-def... 1Gi
# www-web-2 Bound pvc-ghi... 1Gi
# Each pod has its own storage
kubectl exec web-0 -- cat /usr/share/nginx/html/index.html
# <h1>Hello from web-0</h1>
kubectl exec web-1 -- cat /usr/share/nginx/html/index.html
# <h1>Hello from web-1</h1>
# Delete a pod
kubectl delete pod web-1
# StatefulSet recreates it with SAME NAME
kubectl get pods
# web-1 gets recreated (not web-1-xyz like Deployment)
# And uses the SAME PVC
kubectl exec web-1 -- cat /usr/share/nginx/html/index.html
# <h1>Hello from web-1</h1> # Same data!┌─────────────────────┐
│ StorageClass │ "Type" of storage (SSD, HDD, cloud provider)
│ (Admin creates) │ Automatically provisions PVs
└──────────┬──────────┘
│
│ provisions
↓
┌─────────────────────┐
│ PersistentVolume │ Actual storage resource in cluster
│ (Auto-created) │ Has size, access mode, storage backend
└──────────┬──────────┘
│
│ binds to
↓
┌─────────────────────┐
│ PVC │ User's request for storage
│ (User creates) │ "I need 5GB of storage"
└──────────┬──────────┘
│
│ used by
↓
┌─────────────────────┐
│ Pod │ Consumes the storage
│ (User creates) │ Mounts PVC as volume
└─────────────────────┘
# ConfigMap mounted as volume (Chapter 5)
volumes:
- name: config
configMap:
name: app-configUse for: Configuration files, static content
# emptyDir for temporary files
volumes:
- name: cache
emptyDir: {}Use for: Cache, temporary processing files
# PVC for databases
volumes:
- name: db-storage
persistentVolumeClaim:
claimName: postgres-pvcUse for: Databases, file uploads, any data that must survive
# PVC with ReadWriteMany (if supported)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-pvc
spec:
accessModes:
- ReadWriteMany # Multiple pods can use it
resources:
requests:
storage: 10GiUse for: Shared file systems, media processing pipelines
Deploy a Redis instance with persistent storage that survives pod restarts.
Requirements:
- Create PVC requesting 1Gi storage
- Deploy Redis pod using the PVC
- Store some data in Redis
- Delete and recreate the pod
- Verify data is still there
Hints:
# Redis stores data in /data
volumeMounts:
- name: redis-storage
mountPath: /data
# Redis image
image: redis:7.2
# To test Redis:
kubectl exec -it <pod> -- redis-cli
> SET mykey "Hello"
> GET mykeyTry it yourself first. Solution below.
Solution (click to expand)
File: redis-persistent.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
name: redis
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7.2
ports:
- containerPort: 6379
volumeMounts:
- name: redis-storage
mountPath: /data
command: ['redis-server', '--appendonly', 'yes']
volumes:
- name: redis-storage
persistentVolumeClaim:
claimName: redis-pvcTest it:
# Deploy
kubectl apply -f redis-persistent.yaml
# Wait for ready
kubectl wait --for=condition=ready pod/redis --timeout=60s
# Store data
kubectl exec -it redis -- redis-cli SET name "Kubernetes Student"
kubectl exec -it redis -- redis-cli SET course "K8s Storage"
# Verify
kubectl exec -it redis -- redis-cli GET name
# Output: "Kubernetes Student"
# Delete pod
kubectl delete pod redis
# Recreate
kubectl apply -f redis-persistent.yaml
kubectl wait --for=condition=ready pod/redis --timeout=60s
# Check data still exists
kubectl exec -it redis -- redis-cli GET name
# Output: "Kubernetes Student"
kubectl exec -it redis -- redis-cli GET course
# Output: "K8s Storage"
# Data survived!Explanation:
--appendonly yesenables Redis persistence- Data is written to
/datadirectory - PVC ensures
/datasurvives pod restarts - Each pod restart reconnects to same PVC
# Delete pod
kubectl delete pod my-pod
# PVC still exists!
kubectl get pvc
# my-pvc still there
# To fully clean up:
kubectl delete pvc my-pvcWhy it matters: PVCs cost money in cloud environments even if unused.
accessModes:
- ReadWriteMany # Not all storage classes support this!Error:
PersistentVolumeClaim is not bound: no persistent volumes available
Solution: Check storage class capabilities. Most only support ReadWriteOnce.
kubectl delete pvc my-pvc
# Hangs... PVC stuck in "Terminating"Why: PVC is protected while in use.
Solution: Delete pod first, then PVC.
# Pod can fill entire disk
volumeMounts:
- name: logs
mountPath: /var/logSolution: Use ephemeral storage limits:
resources:
limits:
ephemeral-storage: "2Gi"# Create pod with PVC
kubectl apply -f pod-with-pvc-A.yaml
# Delete pod and PVC
kubectl delete pod my-pod
kubectl delete pvc pvc-A
# Create new PVC with different name
kubectl apply -f pod-with-pvc-B.yaml
# Data is gone! (different PVC = different storage)Solution: Keep same PVC name if you want same data.
kubectl delete pod shared-storage-pod hostpath-pod pvc-pod mysql redis
kubectl delete statefulset web
kubectl delete service nginx-headless
kubectl delete pvc my-pvc mysql-pvc redis-pvc
kubectl delete pvc www-web-0 www-web-1 www-web-2 # StatefulSet PVCs- Pods are ephemeral - By default, data is lost when pod restarts
- emptyDir for sharing - Temporary storage shared between containers in pod
- hostPath for node access - Use cautiously, not portable
- PVC for real persistence - Storage that survives pod lifecycle
- StatefulSet for stateful apps - Stable names + automatic PVC per pod
- Access modes matter - Most storage only supports ReadWriteOnce
| Storage Type | Use When | Survives Pod Restart? |
|---|---|---|
| emptyDir | Temporary cache, scratch space | No |
| hostPath | Node logs, Docker socket | Yes (if same node) |
| configMap | Static config files | N/A (not for data) |
| secret | Credentials, certs | N/A (not for data) |
| PVC (RWO) | Database, single-pod app data | Yes |
| PVC (RWX) | Shared files, media processing | Yes |
| StatefulSet + PVC | Databases, distributed systems | Yes |
- Create persistent storage that survives pod restarts
- Use PVCs for databases and stateful applications
- Share data between containers with emptyDir
- Deploy StatefulSets for stable pod identities
- Understand storage classes and access modes
- Choose right storage pattern for your use case
You can store data. But how do you expose apps to the internet? NodePort isn't production-ready.
Next: Chapter 7: Ingress - The Front Door (Coming Soon)
Estimated time: 2-3 hours
Difficulty: Intermediate
Key command: kubectl get pvc - Check your persistent storage claims