Kubernetes Storage, ConfigMaps & Secrets
Quick answer
Externalise configuration with ConfigMaps and Secrets, persist data with PersistentVolumes and StorageClasses, and run stateful workloads correctly with StatefulSets.
- ConfigMaps — Externalising Configuration
- Secrets — Sensitive Configuration
- Volumes — Types and When to Use Them
- PersistentVolumes — Data That Survives Pod Restarts
- StatefulSets — Stateful Workloads
intermediate · 50 min
Before you begin
- Kubernetes core concepts — Pods, Deployments, Services
- Docker Compose experience with volumes and environment variables
Kubernetes Storage, ConfigMaps & Secrets
Two things every real application needs: configuration that changes between environments, and data that survives a Pod restart. Kubernetes handles both — ConfigMaps and Secrets for configuration, PersistentVolumes for data.
ConfigMaps — Externalising Configuration
A ConfigMap stores non-sensitive configuration as key-value pairs. Think of it as the Kubernetes equivalent of an .env file — but stored in the cluster and mountable as files or environment variables.
1# configmap.yaml
2apiVersion: v1
3kind: ConfigMap
4metadata:
5 name: app-config
6data:
7 NODE_ENV: production
8 LOG_LEVEL: info
9 DB_HOST: db
10 DB_PORT: "5432"
11 # You can also store entire files as values
12 nginx.conf: |
13 server {
14 listen 80;
15 location / {
16 proxy_pass http://api:3000;
17 }
18 }kubectl apply -f configmap.yaml
kubectl get configmap app-config
kubectl describe configmap app-configUsing a ConfigMap as environment variables
1spec:
2 containers:
3 - name: api
4 image: myapp:1.0.0
5 # Load specific keys
6 env:
7 - name: NODE_ENV
8 valueFrom:
9 configMapKeyRef:
10 name: app-config
11 key: NODE_ENV
12 - name: LOG_LEVEL
13 valueFrom:
14 configMapKeyRef:
15 name: app-config
16 key: LOG_LEVEL
17 # Or load all keys at once
18 envFrom:
19 - configMapRef:
20 name: app-configMounting a ConfigMap as files
1spec:
2 containers:
3 - name: nginx
4 image: nginx:1.27-alpine
5 volumeMounts:
6 - name: nginx-config
7 mountPath: /etc/nginx/nginx.conf
8 subPath: nginx.conf # Mount a single key as a file
9 volumes:
10 - name: nginx-config
11 configMap:
12 name: app-configWhen mounted as a volume, Kubernetes automatically updates the files when the ConfigMap changes (within ~60 seconds). Environment variables injected at startup do not update without a Pod restart.
Secrets — Sensitive Configuration
Secrets work identically to ConfigMaps but values are base64-encoded. Important: base64 is encoding, not encryption. Anyone with access to the Secret object can decode it instantly.
echo -n "secret123" | base64 # c2VjcmV0MTIz
echo "c2VjcmV0MTIz" | base64 -d # secret123For real secret management, enable encryption at rest in etcd or use an external secret store (ESO + AWS Secrets Manager/Vault).
1# secret.yaml
2apiVersion: v1
3kind: Secret
4metadata:
5 name: app-secrets
6type: Opaque
7stringData: # Use stringData — Kubernetes base64-encodes for you
8 DB_PASSWORD: secret123
9 API_KEY: myapikey
10 JWT_SECRET: supersecretjwtkey1# Or create imperatively without writing values to a file
2kubectl create secret generic app-secrets \
3 --from-literal=DB_PASSWORD=secret123 \
4 --from-literal=API_KEY=myapikey
5
6kubectl get secret app-secrets
7kubectl describe secret app-secrets # Values are not shown
8kubectl get secret app-secrets -o jsonpath='{.data.DB_PASSWORD}' | base64 -dUsing a Secret in a Pod
1spec:
2 containers:
3 - name: api
4 image: myapp:1.0.0
5 env:
6 - name: DB_PASSWORD
7 valueFrom:
8 secretKeyRef:
9 name: app-secrets
10 key: DB_PASSWORD
11 envFrom:
12 - secretRef:
13 name: app-secrets # Load all keys as env varsTLS Secrets
kubectl create secret tls my-tls-cert \
--cert=tls.crt \
--key=tls.keyDocker registry Secrets (image pull)
1kubectl create secret docker-registry ghcr-auth \
2 --docker-server=ghcr.io \
3 --docker-username=myuser \
4 --docker-password=ghp_token
5
6# Reference in Pod spec
7spec:
8 imagePullSecrets:
9 - name: ghcr-authVolumes — Types and When to Use Them
| Volume type | Lifecycle | Use case |
|---|---|---|
emptyDir | Pod (deleted on pod exit) | Scratch space, shared between containers in the same pod |
hostPath | Node | Access node filesystem (dangerous — avoid in production) |
configMap | ConfigMap object | Mount config files |
secret | Secret object | Mount TLS certs, credentials as files |
persistentVolumeClaim | Independent | Databases, stateful workloads |
1spec:
2 containers:
3 - name: api
4 volumeMounts:
5 - name: tmp-dir
6 mountPath: /tmp
7 - name: config
8 mountPath: /etc/app/config.yaml
9 subPath: config.yaml
10 volumes:
11 - name: tmp-dir
12 emptyDir: {}
13 - name: config
14 configMap:
15 name: app-configPersistentVolumes — Data That Survives Pod Restarts
Three objects to understand:
- PersistentVolume (PV) — a piece of actual storage (cloud disk, NFS share, etc.)
- PersistentVolumeClaim (PVC) — a request for storage by a workload
- StorageClass — defines how to dynamically provision PVs (the most common path)
Dynamic provisioning with a StorageClass
In most clusters (EKS, GKE, AKS, and many local setups), you create a PVC and the cluster automatically provisions a PV:
1# pvc.yaml
2apiVersion: v1
3kind: PersistentVolumeClaim
4metadata:
5 name: db-storage
6spec:
7 accessModes:
8 - ReadWriteOnce # One node can read+write at a time (for block storage)
9 resources:
10 requests:
11 storage: 20Gi
12 storageClassName: gp3 # EKS — AWS GP3 SSD. Use "standard" for local/minikubeAccess modes
| Mode | Abbreviation | Meaning |
|---|---|---|
ReadWriteOnce | RWO | One node read+write — block storage (EBS, PD) |
ReadOnlyMany | ROX | Many nodes read-only |
ReadWriteMany | RWX | Many nodes read+write — needs NFS/EFS/Ceph |
Block storage (AWS EBS, GCP Persistent Disk) only supports RWO. If multiple pods on different nodes need to share a volume, you need NFS or a distributed filesystem.
Using a PVC in a Pod
1spec:
2 containers:
3 - name: postgres
4 image: postgres:16-alpine
5 volumeMounts:
6 - name: data
7 mountPath: /var/lib/postgresql/data
8 volumes:
9 - name: data
10 persistentVolumeClaim:
11 claimName: db-storagekubectl get pvc
# NAME STATUS VOLUME CAPACITY ACCESS MODES
# db-storage Bound pvc-abc123... 20Gi RWOBound means a PV has been provisioned and assigned.
StatefulSets — Stateful Workloads
Deployments are for stateless services (any replica is interchangeable). StatefulSets are for stateful workloads where each instance needs:
- A stable, unique identity (
db-0,db-1,db-2) - Its own persistent storage (each replica gets its own PVC)
- Ordered, predictable startup and shutdown
1# statefulset.yaml
2apiVersion: apps/v1
3kind: StatefulSet
4metadata:
5 name: db
6spec:
7 serviceName: db # Must match a Headless Service
8 replicas: 1
9 selector:
10 matchLabels:
11 app: db
12 template:
13 metadata:
14 labels:
15 app: db
16 spec:
17 containers:
18 - name: postgres
19 image: postgres:16-alpine
20 env:
21 - name: POSTGRES_DB
22 valueFrom:
23 configMapKeyRef:
24 name: app-config
25 key: DB_NAME
26 - name: POSTGRES_PASSWORD
27 valueFrom:
28 secretKeyRef:
29 name: app-secrets
30 key: DB_PASSWORD
31 - name: PGDATA
32 value: /var/lib/postgresql/data/pgdata
33 volumeMounts:
34 - name: data
35 mountPath: /var/lib/postgresql/data
36 resources:
37 requests:
38 cpu: "250m"
39 memory: "512Mi"
40 limits:
41 cpu: "1"
42 memory: "1Gi"
43 volumeClaimTemplates: # Each replica gets its own PVC
44 - metadata:
45 name: data
46 spec:
47 accessModes: ["ReadWriteOnce"]
48 resources:
49 requests:
50 storage: 20Gi
51 storageClassName: gp3
52---
53apiVersion: v1
54kind: Service
55metadata:
56 name: db
57spec:
58 clusterIP: None # Headless — enables stable DNS per replica
59 selector:
60 app: db
61 ports:
62 - port: 5432StatefulSet pods get stable DNS names via the Headless Service:
db-0.db.default.svc.cluster.localdb-1.db.default.svc.cluster.local
When db-0 is deleted and recreated, it gets the same PVC and the same DNS name — its identity is preserved.
init Containers — Setup Before the App Starts
init containers run to completion before the main containers start. Use them for:
- Database migrations
- Waiting for a dependency to be ready
- Pre-populating a volume
1spec:
2 initContainers:
3 - name: wait-for-db
4 image: busybox
5 command: ['sh', '-c', 'until nc -z db 5432; do sleep 2; done']
6 - name: run-migrations
7 image: myapp:1.0.0
8 command: ['node', 'dist/migrate.js']
9 env:
10 - name: DB_PASSWORD
11 valueFrom:
12 secretKeyRef:
13 name: app-secrets
14 key: DB_PASSWORD
15 containers:
16 - name: api
17 image: myapp:1.0.0init containers run in order — wait-for-db must exit 0 before run-migrations starts, which must exit 0 before api starts.
Putting It Together — Full Stack Example
1# Full stack: API + PostgreSQL
2apiVersion: v1
3kind: ConfigMap
4metadata:
5 name: app-config
6data:
7 NODE_ENV: production
8 DB_HOST: db
9 DB_PORT: "5432"
10 DB_NAME: myapp
11---
12apiVersion: v1
13kind: Secret
14metadata:
15 name: app-secrets
16stringData:
17 DB_PASSWORD: changeme
18---
19apiVersion: apps/v1
20kind: Deployment
21metadata:
22 name: api
23spec:
24 replicas: 2
25 selector:
26 matchLabels:
27 app: api
28 template:
29 metadata:
30 labels:
31 app: api
32 spec:
33 initContainers:
34 - name: wait-for-db
35 image: busybox
36 command: ['sh', '-c', 'until nc -z db 5432; do sleep 2; done']
37 containers:
38 - name: api
39 image: myapp:1.0.0
40 envFrom:
41 - configMapRef:
42 name: app-config
43 - secretRef:
44 name: app-secrets
45 readinessProbe:
46 httpGet:
47 path: /health
48 port: 3000
49 initialDelaySeconds: 5
50 periodSeconds: 10
51---
52apiVersion: v1
53kind: Service
54metadata:
55 name: api
56spec:
57 selector:
58 app: api
59 ports:
60 - port: 80
61 targetPort: 3000
62---
63apiVersion: apps/v1
64kind: StatefulSet
65metadata:
66 name: db
67spec:
68 serviceName: db
69 replicas: 1
70 selector:
71 matchLabels:
72 app: db
73 template:
74 metadata:
75 labels:
76 app: db
77 spec:
78 containers:
79 - name: postgres
80 image: postgres:16-alpine
81 env:
82 - name: POSTGRES_DB
83 valueFrom:
84 configMapKeyRef:
85 name: app-config
86 key: DB_NAME
87 - name: POSTGRES_PASSWORD
88 valueFrom:
89 secretKeyRef:
90 name: app-secrets
91 key: DB_PASSWORD
92 - name: PGDATA
93 value: /var/lib/postgresql/data/pgdata
94 volumeMounts:
95 - name: data
96 mountPath: /var/lib/postgresql/data
97 volumeClaimTemplates:
98 - metadata:
99 name: data
100 spec:
101 accessModes: ["ReadWriteOnce"]
102 resources:
103 requests:
104 storage: 20Gi
105---
106apiVersion: v1
107kind: Service
108metadata:
109 name: db
110spec:
111 clusterIP: None
112 selector:
113 app: db
114 ports:
115 - port: 5432Frequently Asked Questions
Why did my pods not restart when I changed a ConfigMap?
Only changes to the pod template trigger a rollout, and editing a ConfigMap does not alter the template. A mounted ConfigMap updates in place after a delay; one consumed as environment variables is fixed for the container's life. Either roll out the Deployment explicitly, or include a hash of the config in a pod annotation so the template changes.
Are Kubernetes Secrets encrypted?
Base64-encoded, not encrypted, unless you enable encryption at rest — and even then the API server decrypts on read. Restrict RBAC on Secrets, remember that anyone who can create a pod in a namespace can usually read its secrets, and prefer short-lived dynamic credentials for anything high-value.
When should I use an init container?
For setup that must complete before the application starts and does not belong in the application image — waiting on a dependency, running a migration, fetching a file. They run to completion in order, so a failing init container blocks the pod, which is usually what you want rather than an app that starts and immediately fails.
emptyDir or a PersistentVolume?
emptyDir lives and dies with the pod, which suits scratch space and sharing files between containers in the same pod. Anything that must survive a reschedule needs a PersistentVolume. If losing the data would matter, emptyDir is the wrong choice regardless of how convenient it is.
What's Next
- Kubernetes RBAC & Security — service accounts, roles, and pod security contexts
- For production secret management: look at the Secrets Management guide — ESO with AWS Secrets Manager or HashiCorp Vault
Official References
- Persistent Volumes — access modes, reclaim policies and binding
- Storage Classes — provisioner parameters and volume binding modes
Next in Kubernetes Foundations
Kubernetes RBAC & Security
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.