Part ofKubernetes Foundations·Step 3 of 4
DevOps & Platform

Kubernetes Storage, ConfigMaps & Secrets

Intermediate50 min to complete15 min readJune 1, 2026Updated August 19, 2026

Quick answer

Externalise configuration with ConfigMaps and Secrets, persist data with PersistentVolumes and StorageClasses, and run stateful workloads correctly with StatefulSets.

intermediate · 50 min

Before you begin

  • Kubernetes core concepts — Pods, Deployments, Services
  • Docker Compose experience with volumes and environment variables
Kubernetes
Storage
ConfigMaps
Secrets
PersistentVolumes
StatefulSets

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.

yaml
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    }
bash
kubectl apply -f configmap.yaml
kubectl get configmap app-config
kubectl describe configmap app-config

Using a ConfigMap as environment variables

yaml
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-config

Mounting a ConfigMap as files

yaml
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-config

When 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.

bash
echo -n "secret123" | base64   # c2VjcmV0MTIz
echo "c2VjcmV0MTIz" | base64 -d   # secret123

For real secret management, enable encryption at rest in etcd or use an external secret store (ESO + AWS Secrets Manager/Vault).

yaml
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: supersecretjwtkey
bash
1# 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 -d

Using a Secret in a Pod

yaml
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 vars

TLS Secrets

bash
kubectl create secret tls my-tls-cert \
  --cert=tls.crt \
  --key=tls.key

Docker registry Secrets (image pull)

bash
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-auth

Volumes — Types and When to Use Them

Volume typeLifecycleUse case
emptyDirPod (deleted on pod exit)Scratch space, shared between containers in the same pod
hostPathNodeAccess node filesystem (dangerous — avoid in production)
configMapConfigMap objectMount config files
secretSecret objectMount TLS certs, credentials as files
persistentVolumeClaimIndependentDatabases, stateful workloads
yaml
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-config

PersistentVolumes — 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:

yaml
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/minikube

Access modes

ModeAbbreviationMeaning
ReadWriteOnceRWOOne node read+write — block storage (EBS, PD)
ReadOnlyManyROXMany nodes read-only
ReadWriteManyRWXMany 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

yaml
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-storage
bash
kubectl get pvc
# NAME          STATUS   VOLUME          CAPACITY   ACCESS MODES
# db-storage    Bound    pvc-abc123...   20Gi       RWO

Bound 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
yaml
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: 5432

StatefulSet pods get stable DNS names via the Headless Service:

  • db-0.db.default.svc.cluster.local
  • db-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
yaml
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.0

init 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

yaml
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: 5432

Frequently 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

Official References

Next in Kubernetes Foundations

Kubernetes RBAC & Security

Continue

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.