Skip to main content
Kubernetes · lesson 6 of 10
Kubernetes intermediate Lesson 6 of 10

ConfigMaps, Secrets, and Storage

Keep config out of images with ConfigMaps and Secrets, share files via emptyDir, and keep data through Pod deletion with a PVC, all run on minikube.

By SkillByExample · Updated

The examples run on a local minikube cluster (Kubernetes v1.35). Generated names, IDs, timestamps and ages will differ on yours.

ConfigMaps

A ConfigMap stores non-sensitive configuration as key-value pairs, so the same image runs with different settings in each environment.

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  APP_ENV: "production"
  LOG_LEVEL: "info"
  MAX_CONNECTIONS: "100"
  config.json: |
    {
      "featureFlags": {
        "darkMode": true,
        "betaApi": false
      }
    }
kubectl apply -f configmap.yaml
kubectl get configmap app-config
kubectl describe configmap app-config
$ kubectl apply -f configmap.yaml
configmap/app-config created
$ kubectl get configmap app-config
NAME         DATA   AGE
app-config   4      0s
$ kubectl describe configmap app-config
Name:         app-config
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
APP_ENV:
----
production

LOG_LEVEL:
----
info

MAX_CONNECTIONS:
----
100

config.json:
----
{
  "featureFlags": {
    "darkMode": true,
    "betaApi": false
  }
}



BinaryData
====

Events:  <none>

DATA 4 counts keys, and a whole JSON document is a single key. Every value is a string, which is why "100" is quoted in the YAML. A bare 100 would be rejected.

You can also build ConfigMaps without writing YAML, from a file or from literals. The file:

# nginx.conf
server {
    listen 80;
    location / { return 200 "ok\n"; }
}
kubectl create configmap nginx-conf --from-file=nginx.conf
kubectl create configmap db-config --from-literal=DB_HOST=postgres --from-literal=DB_PORT=5432
kubectl get configmap nginx-conf -o jsonpath='{.data}'; echo
kubectl get configmap db-config -o jsonpath='{.data}'; echo
$ kubectl create configmap nginx-conf --from-file=nginx.conf
configmap/nginx-conf created
$ kubectl create configmap db-config --from-literal=DB_HOST=postgres --from-literal=DB_PORT=5432
configmap/db-config created
$ kubectl get configmap nginx-conf -o jsonpath='{.data}'; echo
{"nginx.conf":"server {\n    listen 80;\n    location / { return 200 \"ok\\n\"; }\n}\n"}
$ kubectl get configmap db-config -o jsonpath='{.data}'; echo
{"DB_HOST":"postgres","DB_PORT":"5432"}

--from-file uses the file name as the key and the whole file as the value. --from-literal makes one key per flag. Even 5432 comes back as the string "5432".

Using ConfigMap as Environment Variables

envFrom injects every key; env with configMapKeyRef picks one key and can rename it. The Pod just prints what it received.

# env-demo.yaml
apiVersion: v1
kind: Pod
metadata:
  name: env-demo
spec:
  restartPolicy: Never
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "env | sort | grep -E 'APP_ENV|LOG_LEVEL|MAX_|SPECIFIC|config'"]
      envFrom:
        - configMapRef:
            name: app-config     # inject all keys as env vars
      env:
        - name: SPECIFIC_KEY     # inject a single key
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: LOG_LEVEL
kubectl apply -f env-demo.yaml
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/env-demo
kubectl logs env-demo
$ kubectl apply -f env-demo.yaml
pod/env-demo created
$ kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/env-demo
pod/env-demo condition met
$ kubectl logs env-demo
APP_ENV=production
LOG_LEVEL=info
MAX_CONNECTIONS=100
SPECIFIC_KEY=info
config.json={

SPECIFIC_KEY holds the value of LOG_LEVEL under a different name. config.json was injected too, even though it is an unusual variable name. Recent Kubernetes versions accept any printable name without =, while older ones skipped such keys and recorded an event. Its value is the multi-line JSON, and only the first line contains config, so grep printed just {. Use files, not environment variables, for structured config.

Using ConfigMap as a Mounted File

Mounted as a volume, each key becomes a file named after the key.

# mount-demo.yaml
apiVersion: v1
kind: Pod
metadata:
  name: mount-demo
spec:
  volumes:
    - name: config-vol
      configMap:
        name: app-config
  containers:
    - name: app
      image: busybox:1.36
      command: ["sleep", "3600"]
      volumeMounts:
        - name: config-vol
          mountPath: /etc/config   # each key becomes a file
kubectl apply -f mount-demo.yaml
kubectl wait --for=condition=Ready pod/mount-demo
kubectl exec mount-demo -- ls /etc/config
kubectl exec mount-demo -- cat /etc/config/config.json
kubectl exec mount-demo -- ls -la /etc/config
$ kubectl apply -f mount-demo.yaml
pod/mount-demo created
$ kubectl wait --for=condition=Ready pod/mount-demo
pod/mount-demo condition met
$ kubectl exec mount-demo -- ls /etc/config
APP_ENV
LOG_LEVEL
MAX_CONNECTIONS
config.json
$ kubectl exec mount-demo -- cat /etc/config/config.json
{
  "featureFlags": {
    "darkMode": true,
    "betaApi": false
  }
}
$ kubectl exec mount-demo -- ls -la /etc/config
total 12
drwxrwxrwx    3 root     root          4096 Oct 10 20:29 .
drwxr-xr-x    1 root     root          4096 Oct 10 20:29 ..
drwxr-xr-x    2 root     root          4096 Oct 10 20:29 ..2026_10_10_20_29_26.472617166
lrwxrwxrwx    1 root     root            31 Oct 10 20:29 ..data -> ..2026_10_10_20_29_26.472617166
lrwxrwxrwx    1 root     root            14 Oct 10 20:29 APP_ENV -> ..data/APP_ENV
lrwxrwxrwx    1 root     root            16 Oct 10 20:29 LOG_LEVEL -> ..data/LOG_LEVEL
lrwxrwxrwx    1 root     root            22 Oct 10 20:29 MAX_CONNECTIONS -> ..data/MAX_CONNECTIONS
lrwxrwxrwx    1 root     root            18 Oct 10 20:29 config.json -> ..data/config.json

config.json arrives as a real file with its formatting intact. ls -la shows how updates work: each key is a symlink through ..data into a timestamped directory. When the ConfigMap changes, the kubelet writes a new directory and swaps the ..data link in one step, so the app never reads a half-written set of files. Practice task 1 shows this.

Secrets

A Secret has the same shape as a ConfigMap, but its data values are base64-encoded and Kubernetes treats the object as sensitive.

# secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-secret
type: Opaque
data:
  DB_PASSWORD: cGFzc3dvcmQxMjM=   # base64("password123")
  API_KEY: c3VwZXJzZWNyZXQ=       # base64("supersecret")
echo -n "password123" | base64
kubectl apply -f secret.yaml
kubectl get secret db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 --decode; echo
$ echo -n "password123" | base64
cGFzc3dvcmQxMjM=
$ kubectl apply -f secret.yaml
secret/db-secret created
$ kubectl get secret db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 --decode; echo
password123

-n matters: without it, echo adds a newline and the encoded value becomes cGFzc3dvcmQxMjMK. That trailing K is a newline baked into the password. The decode step shows that base64 is an encoding, not encryption. Anyone who can get the Secret can read it.

Creating the Secret imperatively keeps plaintext out of files you might commit, but create refuses to overwrite:

kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret
kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret --dry-run=client -o yaml
kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret --dry-run=client -o yaml | kubectl apply -f -
$ kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret
error: failed to create secret secrets "db-secret" already exists
$ kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret --dry-run=client -o yaml
apiVersion: v1
data:
  API_KEY: c3VwZXJzZWNyZXQ=
  DB_PASSWORD: cGFzc3dvcmQxMjM=
kind: Secret
metadata:
  name: db-secret
$ kubectl create secret generic db-secret --from-literal=DB_PASSWORD=password123 --from-literal=API_KEY=supersecret --dry-run=client -o yaml | kubectl apply -f -
secret/db-secret configured

--dry-run=client -o yaml only prints the object, and its base64 matches secret.yaml exactly. Piping that into kubectl apply -f - is the usual create-or-update idiom.

A TLS Secret holds a certificate and key under the fixed names tls.crt and tls.key. A self-signed pair is enough to try it:

openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=myapp.local" -keyout tls.key -out tls.crt
kubectl create secret tls my-tls --cert=tls.crt --key=tls.key
kubectl get secret my-tls
kubectl describe secret my-tls | tail -5
$ openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=myapp.local" -keyout tls.key -out tls.crt
Generating a RSA private key
.................................+++++
.........................+++++
writing new private key to 'tls.key'
-----
$ kubectl create secret tls my-tls --cert=tls.crt --key=tls.key
secret/my-tls created
$ kubectl get secret my-tls
NAME     TYPE                DATA   AGE
my-tls   kubernetes.io/tls   2      0s
$ kubectl describe secret my-tls | tail -5

Data
====
tls.crt:  1138 bytes
tls.key:  1732 bytes

The type kubernetes.io/tls is what an Ingress tls: section expects. describe shows only sizes, never values. That hides them from screen output but is not access control.

Using Secrets in Pods

Secrets are consumed exactly like ConfigMaps, with secretRef and secretKeyRef.

# secret-env.yaml
apiVersion: v1
kind: Pod
metadata:
  name: secret-env
spec:
  restartPolicy: Never
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "echo DB_PASSWORD=$DB_PASSWORD; echo API_KEY=$API_KEY"]
      envFrom:
        - secretRef:
            name: db-secret      # inject all secret keys as env vars
      env:
        - name: DB_PASSWORD      # or pick one key explicitly
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: DB_PASSWORD
kubectl apply -f secret-env.yaml
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/secret-env
kubectl logs secret-env
$ kubectl apply -f secret-env.yaml
pod/secret-env created
$ kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/secret-env
pod/secret-env condition met
$ kubectl logs secret-env
DB_PASSWORD=password123
API_KEY=supersecret

The container sees decoded plaintext, and the kubelet does the base64 decoding. That is also why logging environment variables, as this demo does on purpose, leaks secrets into kubectl logs. When a key arrives through both envFrom and env, the explicit env entry wins.

Volumes and Persistent Storage

emptyDir — Temporary Shared Storage

An emptyDir lives as long as the Pod, and every container in the Pod can mount it, which makes it the usual way for sidecars to share files.

# emptydir.yaml
apiVersion: v1
kind: Pod
metadata:
  name: shared-scratch
spec:
  volumes:
    - name: scratch
      emptyDir: {}
  containers:
    - name: writer
      image: busybox:1.36
      command: ["sh", "-c", "echo hello > /data/msg; sleep 3600"]
      volumeMounts:
        - name: scratch
          mountPath: /data
    - name: reader
      image: busybox:1.36
      command: ["sh", "-c", "sleep 2; cat /data/msg; sleep 3600"]
      volumeMounts:
        - name: scratch
          mountPath: /data
kubectl apply -f emptydir.yaml
kubectl wait --for=condition=Ready pod/shared-scratch
kubectl logs shared-scratch -c reader
kubectl exec shared-scratch -c reader -- sh -c 'echo from-reader >> /data/msg'
kubectl exec shared-scratch -c writer -- cat /data/msg
$ kubectl apply -f emptydir.yaml
pod/shared-scratch created
$ kubectl wait --for=condition=Ready pod/shared-scratch
pod/shared-scratch condition met
$ kubectl logs shared-scratch -c reader
hello
$ kubectl exec shared-scratch -c reader -- sh -c 'echo from-reader >> /data/msg'
$ kubectl exec shared-scratch -c writer -- cat /data/msg
hello
from-reader

Both containers see one directory: the reader printed the writer’s hello, and the writer sees the line the reader appended. The kubelet starts a Pod’s containers in spec order but doesn’t wait for one to be ready before starting the next, so the reader waits two seconds before reading. Without the sleep 2 it can run cat before the file exists. -c picks the container for logs and exec in a multi-container Pod.

PersistentVolume and PersistentVolumeClaim

A PersistentVolumeClaim (PVC) asks for storage by size and access mode. A StorageClass’s provisioner creates the matching PersistentVolume (PV) for you.

# pvc.yaml — request storage (you don't manage where it comes from)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-pvc
spec:
  accessModes:
    - ReadWriteOnce        # one node can read/write at a time
  resources:
    requests:
      storage: 5Gi
  storageClassName: standard   # use "standard" for minikube
kubectl apply -f pvc.yaml
kubectl get pvc
kubectl describe pvc postgres-pvc
kubectl get pv
$ kubectl apply -f pvc.yaml
persistentvolumeclaim/postgres-pvc created
$ kubectl get pvc
NAME           STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
postgres-pvc   Bound    pvc-163b95f5-1179-4d47-bbfe-dd8dffc00f4d   5Gi        RWO            standard       <unset>                 1s
$ kubectl describe pvc postgres-pvc
Name:          postgres-pvc
Namespace:     default
StorageClass:  standard
Status:        Bound
Volume:        pvc-163b95f5-1179-4d47-bbfe-dd8dffc00f4d
Labels:        <none>
Annotations:   pv.kubernetes.io/bind-completed: yes
               pv.kubernetes.io/bound-by-controller: yes
               volume.beta.kubernetes.io/storage-provisioner: k8s.io/minikube-hostpath
               volume.kubernetes.io/storage-provisioner: k8s.io/minikube-hostpath
Finalizers:    [kubernetes.io/pvc-protection]
Capacity:      5Gi
Access Modes:  RWO
VolumeMode:    Filesystem
Used By:       <none>
Events:
  Type    Reason                 Age   From                                                                    Message
  ----    ------                 ----  ----                                                                    -------
  Normal  Provisioning           1s    k8s.io/minikube-hostpath_minikube_cc7e1a55-3e3a-495f-8fb9-ad027076b39f  External provisioner is provisioning volume for claim "default/postgres-pvc"
  Normal  ExternalProvisioning   1s    persistentvolume-controller                                             Waiting for a volume to be created either by the external provisioner 'k8s.io/minikube-hostpath' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered.
  Normal  ProvisioningSucceeded  1s    k8s.io/minikube-hostpath_minikube_cc7e1a55-3e3a-495f-8fb9-ad027076b39f  Successfully provisioned volume pvc-163b95f5-1179-4d47-bbfe-dd8dffc00f4d
$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                  STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
pvc-163b95f5-1179-4d47-bbfe-dd8dffc00f4d   5Gi        RWO            Delete           Bound    default/postgres-pvc   standard       <unset>                          2s

Bound means a volume exists and is reserved for this claim. The events show minikube’s hostpath provisioner creating the PV on demand, and the PV is named after the claim’s UID. RECLAIM POLICY Delete means deleting the PVC also deletes the PV and its data. Used By: <none> changes once a Pod mounts the claim.

A Pod mounts the claim by name. This one appends a timestamp on every start, so a second Pod shows whether the first one’s data survived:

# pvc-demo.yaml — a Pod that appends to a file on the claimed volume
apiVersion: v1
kind: Pod
metadata:
  name: pvc-demo
spec:
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: postgres-pvc   # reference the PVC
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "date >> /data/boots.log; cat /data/boots.log; sleep 3600"]
      volumeMounts:
        - name: data
          mountPath: /data
kubectl apply -f pvc-demo.yaml
kubectl wait --for=condition=Ready pod/pvc-demo
kubectl logs pvc-demo
kubectl delete pod pvc-demo
kubectl apply -f pvc-demo.yaml
kubectl wait --for=condition=Ready pod/pvc-demo
kubectl logs pvc-demo
$ kubectl apply -f pvc-demo.yaml
pod/pvc-demo created
$ kubectl wait --for=condition=Ready pod/pvc-demo
pod/pvc-demo condition met
$ kubectl logs pvc-demo
Sat Oct 10 20:30:19 UTC 2026
$ kubectl delete pod pvc-demo
pod "pvc-demo" deleted from default namespace
$ kubectl apply -f pvc-demo.yaml
pod/pvc-demo created
$ kubectl wait --for=condition=Ready pod/pvc-demo
pod/pvc-demo condition met
$ kubectl logs pvc-demo
Sat Oct 10 20:30:19 UTC 2026
Sat Oct 10 20:30:55 UTC 2026

The second Pod is a new object with a fresh container filesystem, yet its log starts with the first Pod’s timestamp: the file lives on the PV, not in the Pod. With an emptyDir the second log would hold one line. The delete is slow because the container’s PID 1 is sh, which installs no SIGTERM handler, and the kernel drops unhandled signals sent to PID 1. The kubelet waits out the 30-second grace period, then sends SIGKILL.

Clear out the demo Pods before the next example, then the claim:

kubectl delete pod env-demo mount-demo secret-env shared-scratch pvc-demo --now
kubectl delete pvc postgres-pvc
kubectl get pv
$ kubectl delete pod env-demo mount-demo secret-env shared-scratch pvc-demo --now
pod "env-demo" deleted from default namespace
pod "mount-demo" deleted from default namespace
pod "secret-env" deleted from default namespace
pod "shared-scratch" deleted from default namespace
pod "pvc-demo" deleted from default namespace
$ kubectl delete pvc postgres-pvc
persistentvolumeclaim "postgres-pvc" deleted from default namespace
$ kubectl get pv
No resources found

--now skips the grace period. Deleting the PVC also removed its PV, which is the Delete reclaim policy at work, and boots.log went with it.

Access Modes

ModeAbbreviationMeaning
ReadWriteOnceRWOSingle node read/write
ReadOnlyManyROXMultiple nodes read-only
ReadWriteManyRWXMultiple nodes read/write

Which modes you can get depends on the provisioner. minikube’s hostpath volumes and most cloud block disks are RWO. RWX needs a shared filesystem such as NFS or a cloud file service.

Complete Database Deployment Example

Postgres takes its database and user from a ConfigMap, its password from a Secret, and keeps its data directory on a PVC.

# postgres-full.yaml
---
apiVersion: v1
kind: Secret
metadata:
  name: pg-secret
data:
  POSTGRES_PASSWORD: cGFzc3dvcmQ=   # "password"

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: pg-config
data:
  POSTGRES_DB: myapp
  POSTGRES_USER: appuser

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pg-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 2Gi

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: pg-pvc
      containers:
        - name: postgres
          image: postgres:15
          envFrom:
            - configMapRef:
                name: pg-config
            - secretRef:
                name: pg-secret
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
          ports:
            - containerPort: 5432
kubectl apply -f postgres-full.yaml
kubectl rollout status deployment/postgres
kubectl get pods,pvc,svc
$ kubectl apply -f postgres-full.yaml
secret/pg-secret created
configmap/pg-config created
persistentvolumeclaim/pg-pvc created
deployment.apps/postgres created
$ kubectl rollout status deployment/postgres
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
deployment "postgres" successfully rolled out
$ kubectl get pods,pvc,svc
NAME                            READY   STATUS    RESTARTS   AGE
pod/postgres-65566cc6db-q2swk   1/1     Running   0          2s

NAME                           STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
persistentvolumeclaim/pg-pvc   Bound    pvc-bd5e9927-8d01-4b3a-ab99-9b38aedc72dd   2Gi        RWO            standard       <unset>                 2s

NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   15m

pg-pvc has no storageClassName, so it got the cluster’s default class (standard on minikube). The only Service is the API server’s kubernetes, because this file defines none. Other Pods would need a ClusterIP Service in front of port 5432, as in the Services lesson.

The Pod is Running before Postgres finishes initialising. If psql reports a refused connection, wait a few seconds. Then check that the ConfigMap and Secret took effect and that data outlives the Pod:

kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "SELECT current_user, current_database();"
kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "CREATE TABLE notes(body text); INSERT INTO notes VALUES ('survives restarts');"
kubectl delete pod -l app=postgres
kubectl rollout status deployment/postgres
kubectl get pods -l app=postgres
kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "SELECT * FROM notes;"
$ kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "SELECT current_user, current_database();"
 current_user | current_database 
--------------+------------------
 appuser      | myapp
(1 row)

$ kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "CREATE TABLE notes(body text); INSERT INTO notes VALUES ('survives restarts');"
CREATE TABLE
INSERT 0 1
$ kubectl delete pod -l app=postgres
pod "postgres-65566cc6db-q2swk" deleted from default namespace
$ kubectl rollout status deployment/postgres
Waiting for deployment "postgres" rollout to finish: 0 of 1 updated replicas are available...
deployment "postgres" successfully rolled out
$ kubectl get pods -l app=postgres
NAME                        READY   STATUS    RESTARTS   AGE
postgres-65566cc6db-wjcpf   1/1     Running   0          2s
$ kubectl exec deploy/postgres -- psql -U appuser -d myapp -c "SELECT * FROM notes;"
       body        
-------------------
 survives restarts
(1 row)

appuser and myapp exist only because the image’s entrypoint read POSTGRES_USER and POSTGRES_DB from the ConfigMap on first start. The replacement Pod (wjcpf) mounted the same PVC, found an initialised data directory, and skipped initialisation, so the table and row were still there. A Deployment is fine for one replica on an RWO volume. To run several database replicas, each with its own volume, use a StatefulSet.

Practice

  1. With mount-demo running, change LOG_LEVEL to debug and watch the mounted file follow.
Solution
kubectl apply -f mount-demo.yaml
kubectl wait --for=condition=Ready pod/mount-demo
kubectl exec mount-demo -- cat /etc/config/LOG_LEVEL; echo
kubectl patch configmap app-config -p '{"data":{"LOG_LEVEL":"debug"}}'; date +%T
# re-run the cat every few seconds until it changes:
date +%T; kubectl exec mount-demo -- cat /etc/config/LOG_LEVEL
$ kubectl apply -f mount-demo.yaml
pod/mount-demo created
$ kubectl wait --for=condition=Ready pod/mount-demo
pod/mount-demo condition met
$ kubectl exec mount-demo -- cat /etc/config/LOG_LEVEL; echo
info
$ kubectl patch configmap app-config -p '{"data":{"LOG_LEVEL":"debug"}}'; date +%T
configmap/app-config patched
02:02:35
$ date +%T; kubectl exec mount-demo -- cat /etc/config/LOG_LEVEL
02:03:42
debug

The file changed about a minute after the patch, without restarting the container. The kubelet syncs mounted ConfigMaps on its own period plus a cache delay. An environment variable from the same key would still say info until the Pod is recreated.

  1. Mount only the config.json key, as /etc/app/settings.json, in a Pod that lists the directory and prints the file.
Solution
# items-demo.yaml
apiVersion: v1
kind: Pod
metadata:
  name: items-demo
spec:
  restartPolicy: Never
  volumes:
    - name: config-vol
      configMap:
        name: app-config
        items:
          - key: config.json
            path: settings.json    # only this key, under a new name
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "ls /etc/app; cat /etc/app/settings.json"]
      volumeMounts:
        - name: config-vol
          mountPath: /etc/app
$ kubectl apply -f items-demo.yaml
pod/items-demo created
$ kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/items-demo
pod/items-demo condition met
$ kubectl logs items-demo
settings.json
{
  "featureFlags": {
    "darkMode": true,
    "betaApi": false
  }
}

With items, only the listed keys are projected, and path renames the file. APP_ENV and the other keys are absent.

  1. Read API_KEY back out of db-secret in plaintext.
Solution
kubectl get secret db-secret -o jsonpath='{.data.API_KEY}' | base64 --decode; echo
$ kubectl get secret db-secret -o jsonpath='{.data.API_KEY}' | base64 --decode; echo
supersecret

Read access to a Secret is read access to its plaintext, which is why RBAC on secrets matters more than the encoding.

  1. Delete pg-pvc while Postgres is still using it. What happens, and what lets the delete finish?
Solution
kubectl delete pvc pg-pvc --wait=false
kubectl get pvc pg-pvc
kubectl scale deployment postgres --replicas=0
kubectl wait --for=delete pvc/pg-pvc
kubectl get pvc,pv
$ kubectl delete pvc pg-pvc --wait=false
persistentvolumeclaim "pg-pvc" deleted from default namespace
$ kubectl get pvc pg-pvc
NAME     STATUS        VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
pg-pvc   Terminating   pvc-bd5e9927-8d01-4b3a-ab99-9b38aedc72dd   2Gi        RWO            standard       <unset>                 2m2s
$ kubectl scale deployment postgres --replicas=0
deployment.apps/postgres scaled
$ kubectl wait --for=delete pvc/pg-pvc
persistentvolumeclaim/pg-pvc condition met
$ kubectl get pvc,pv
No resources found

The kubernetes.io/pvc-protection finalizer keeps a claim in Terminating while any Pod still mounts it, so a running database never loses its disk underneath it. Once the Pod was gone, the claim finished deleting and, with the Delete reclaim policy, the PV and the notes table went with it.

Clean up the rest of the lesson’s objects:

kubectl delete -f postgres-full.yaml --ignore-not-found
kubectl delete pod mount-demo items-demo --now
kubectl delete configmap app-config nginx-conf db-config
kubectl delete secret db-secret my-tls
$ kubectl delete -f postgres-full.yaml --ignore-not-found
secret "pg-secret" deleted from default namespace
configmap "pg-config" deleted from default namespace
deployment.apps "postgres" deleted from default namespace
$ kubectl delete pod mount-demo items-demo --now
pod "mount-demo" deleted from default namespace
pod "items-demo" deleted from default namespace
$ kubectl delete configmap app-config nginx-conf db-config
configmap "app-config" deleted from default namespace
configmap "nginx-conf" deleted from default namespace
configmap "db-config" deleted from default namespace
$ kubectl delete secret db-secret my-tls
secret "db-secret" deleted from default namespace
secret "my-tls" deleted from default namespace

Frequently Asked Questions

When should I use a ConfigMap vs a Secret?
Use a ConfigMap for non-sensitive configuration such as app settings and feature flags, and a Secret for passwords, tokens and certificates. Secrets are only base64-encoded, not encrypted, so production clusters should enable encryption at rest and restrict who can read them with RBAC.
What happens to data in an emptyDir volume when a pod restarts?
An emptyDir volume is created when the Pod is scheduled and survives container restarts within that Pod. It is deleted when the Pod is removed from the node, so a recreated Pod starts with an empty directory. Use a PersistentVolumeClaim for data that must survive Pod deletion.
Do Pods see ConfigMap changes without a restart?
A ConfigMap mounted as a volume is updated in place by the kubelet, typically within a minute or so. Values injected as environment variables are read only when the container starts, so they keep the old value until the Pod is recreated. Keys mounted with subPath are not updated either.
Are Kubernetes Secrets encrypted?
Not by default. The values in a Secret's data field are base64-encoded, which anyone with read access can decode with base64 --decode. Kubernetes can encrypt Secrets at rest in etcd if the API server is configured for it, and RBAC should limit who can get or list Secrets.
Why is my PersistentVolumeClaim stuck in Terminating?
A PVC that is still mounted by a Pod carries the kubernetes.io/pvc-protection finalizer, so deleting it only marks it Terminating. It is removed once no Pod uses it. Delete or scale down the workload using the claim and the PVC finishes deleting.