Your container image should be the same in dev, staging, and production. What changes between those places is configuration: log levels, feature flags, database passwords. In this part you learn how Kubernetes keeps that configuration out of your images using ConfigMaps and Secrets, how to feed it to containers as environment variables or files, and — just as important — what Secrets actually protect and what they absolutely do not.
What you need
- A working local cluster and
kubectlfrom Part 1. - The
clinerds-demoDeployment from Part 4 — we will recreate it below with config wired in, so no worries if your cluster is fresh. - A rough idea of what an environment variable is, and comfort with
kubectl apply -f.
Recipe 1: A ConfigMap for app settings
Problem: Your app needs settings like a log level and a greeting, and you refuse to rebuild the image every time one of them changes.
A ConfigMap is a named bag of plain-text key-value pairs that lives in the cluster. Create one:
apiVersion: v1 kind: ConfigMap metadata: name: clinerds-settings data: APP_ENV: "local-dev" LOG_LEVEL: "info" APP_GREETING: "hello from the configmap" kubectl apply -f configmap-settings.yaml Now update the demo Deployment to inject every key as an environment variable using envFrom:
apiVersion: apps/v1 kind: Deployment metadata: name: clinerds-demo spec: replicas: 1 selector: matchLabels: app: clinerds-demo template: metadata: labels: app: clinerds-demo spec: containers: - name: web image: hashicorp/http-echo args: ["-text=clinerds part 7"] ports: - containerPort: 5678 envFrom: - configMapRef: name: clinerds-settings kubectl apply -f deployment-with-config.yaml kubectl rollout status deploy/clinerds-demo The http-echo image is a single Go binary with no shell inside, so we cannot kubectl exec in to check. Instead, run a throwaway BusyBox pod with the same envFrom and ask it to print what it got:
apiVersion: v1 kind: Pod metadata: name: printenv-check spec: restartPolicy: Never containers: - name: check image: busybox:1.36 command: ["sh", "-c", "printenv | grep -E 'APP_|LOG_'"] envFrom: - configMapRef: name: clinerds-settings kubectl apply -f printenv-check.yaml kubectl logs printenv-check APP_ENV=local-dev LOG_LEVEL=info APP_GREETING=hello from the configmap How it works: When the kubelet (the agent on each node that starts containers) creates a pod, it reads the ConfigMap from the API server and sets one environment variable per key before the container process starts. envFrom with configMapRef means "every key in this map becomes a variable." The values are plain strings — nothing is hidden, so never put passwords here.
Variations:
- To pull in a single key under a custom name, use
valueFrominstead ofenvFrom:env: [{name: GREETING, valueFrom: {configMapKeyRef: {name: clinerds-settings, key: APP_GREETING}}}]. - Add
optional: trueunderconfigMapKeyRefif the pod should still start when the key is missing. - Clean up with
kubectl delete pod printenv-check.
Recipe 2: Mount config as files
Problem: Many tools — nginx, Prometheus, your own app — read configuration from files, not environment variables. You want to hand them a whole config file without baking it into the image.
Put the file's contents in a ConfigMap, one key per file:
apiVersion: v1 kind: ConfigMap metadata: name: web-conf data: server.conf: | server { listen 8080; location / { return 200 "served by configmap\n"; } } The | after the key means "literal multi-line block" in YAML — everything indented below it becomes one string. Mount it into an nginx pod as a volume:
apiVersion: v1 kind: Pod metadata: name: nginx-with-conf spec: containers: - name: nginx image: nginx:1.27 volumeMounts: - name: conf mountPath: /etc/nginx/conf.d readOnly: true volumes: - name: conf configMap: name: web-conf kubectl apply -f configmap-web.yaml kubectl apply -f pod-nginx-conf.yaml kubectl exec nginx-with-conf -- cat /etc/nginx/conf.d/server.conf You should see the exact server block from the ConfigMap. Now edit the ConfigMap (kubectl edit configmap web-conf, change the message), wait about a minute, and cat the file again — it changes on its own.
How it works: Each key in the ConfigMap becomes one file inside the mount directory. The kubelet refreshes mounted ConfigMap volumes on a sync loop (by default once a minute, plus a cache TTL), so file-based config propagates to running pods with a delay of up to roughly a minute or two. Environment variables from Recipe 1 are different: they are set once at process start and never change until the container restarts. Mounted files update; env vars do not.
Variations:
- Mount a single key to a single file with
items: [{key: server.conf, path: server.conf}]under theconfigMapvolume. - Set
immutable: trueon a ConfigMap to freeze it. The API server then rejects edits, which protects you from half-updated pods and lightens the kubelet's watch load. To change it, create a new ConfigMap with a new name and point the pod at it.
Recipe 3: Secrets, done honestly
Problem: Your app needs a database password. A ConfigMap is plaintext for anyone to read, so you reach for a Secret — and you should know exactly what that buys you.
The fastest way to create one:
kubectl create secret generic clinerds-db \ --from-literal=username=app \ --from-literal=password=s3cr3t-pass For YAML you can commit (to a private repo, ideally encrypted there), use stringData so you write plain text and let Kubernetes encode it:
apiVersion: v1 kind: Secret metadata: name: clinerds-db-yaml type: Opaque stringData: username: app password: s3cr3t-pass Here is the part nobody tells beginners loudly enough: base64 is encoding, not encryption. If you kubectl get secret clinerds-db -o yaml, you will see c2NjcjN0LXBhc3M= — anyone can turn that back into the password with one base64 -d. Secrets exist to keep casual accidents from leaking credentials (no more passwords in kubectl describe output or printed env dumps), not to stop a determined reader.
Consume it both ways in one check pod:
apiVersion: v1 kind: Pod metadata: name: secret-check spec: restartPolicy: Never containers: - name: check image: busybox:1.36 command: ["sh", "-c", "echo env-user=$DB_USER; ls -l /etc/db; cat /etc/db/password"] env: - name: DB_USER valueFrom: secretKeyRef: name: clinerds-db key: username volumeMounts: - name: db-secret mountPath: /etc/db readOnly: true volumes: - name: db-secret secret: secretName: clinerds-db defaultMode: 0400 kubectl apply -f pod-secret-check.yaml kubectl logs secret-check env-user=app -r-------- 1 root root 11 Sep 18 09:01 password s3cr3t-pass How it works: secretKeyRef injects one key as one environment variable, decoded for you. The volume form writes each key as a file, and defaultMode: 0400 sets the file permissions to read-only for the owner only — the tightest sensible setting. Same rule as ConfigMaps: env vars are frozen at start, mounted files refresh on the kubelet sync loop. Clean up with kubectl delete pod secret-check.
Recipe 4: Roll pods when config changes
Problem: You edited the ConfigMap from Recipe 1, but your app still shows the old greeting. Nothing restarted — because nothing is supposed to.
kubectl edit configmap clinerds-settings # change APP_GREETING kubectl get pods -l app=clinerds-demo Note the pod's AGE — it did not budge. Kubernetes deliberately does not restart pods when configuration changes; the Deployment only reacts to changes in its own pod template. So tell it the template "changed" by triggering a rollout:
kubectl rollout restart deploy/clinerds-demo kubectl rollout status deploy/clinerds-demo kubectl get pods -l app=clinerds-demo NAME READY STATUS RESTARTS AGE clinerds-demo-6df8d9f7c9-x2vkm 1/1 Running 0 8s clinerds-demo-77b9c5f4b5-qj4nt 1/1 Terminating 0 14m A fresh pod starts, picks up the new env values, and the old one drains. Zero downtime, since the rollout respects your replica count.
How it works: rollout restart adds a timestamp annotation (kubectl.kubernetes.io/restartedAt) to the pod template. To the Deployment controller that is a template change, so it performs a normal rolling update — new pod first, old pod second.
Variations:
- Helm charts automate this with a checksum annotation. In the template:
annotations: {checksum/config: "{{ include (print $.Template.BasePath \"/configmap.yaml\") . | sha256sum }}"}. Any edit to the ConfigMap changes the hash, which changes the template, which triggers a rollout. Same trick, no manual command. - If your app can reload config itself (many read mounted files on a signal), Recipe 2's auto-updating volume may save you the restart entirely.
Recipe 5: The honest limits of Secrets
Problem: You now assume Secrets are safe. Let us check that assumption before it costs you.
kubectl get secret clinerds-db -o jsonpath='{.data.password}' | base64 -d s3cr3t-pass One command, full password. Two hard facts follow. First, anyone whose account can get secrets in that namespace can do exactly what you just did — Secrets are access-controlled, not concealed. Second, by default the API server stores Secrets in etcd (the cluster's database) as plain base64, so anyone who reads an etcd backup or snapshot reads every Secret in the cluster.
How it works: A Secret is "private" only because of RBAC (Role-Based Access Control, the permission system we cover in Part 10) and, if you enable it, encryption at rest. The first decides who may ask the API server for a Secret; the second decides what an etcd file on disk contains.
Variations:
- Turn on encryption at rest by giving the API server an
EncryptionConfigurationwith anaescbcorsecretboxprovider — see the official docs on encrypting secret data at rest. On kind this means extra flags at cluster creation; on managed clusters it is often a checkbox backed by a KMS (Key Management Service). - For real environments, keep secrets in an external manager — HashiCorp Vault, AWS Secrets Manager — and sync them in with a controller like External Secrets Operator. The cluster then holds references, not the master copy.
- Grant Secret access to the smallest set of service accounts possible, and prefer mounted files over env vars: env values leak into crash dumps and
kubectl describe podoutput more easily.
ConfigMap vs Secret: what is the actual difference?
People search "ConfigMap vs Secret" expecting a trick answer. There is not one: both store configuration as key-value pairs, and both are delivered to your pod the same way. The difference is intent and handling, not magic encryption.
| ConfigMap | Secret | |
|---|---|---|
| What belongs in it | Settings: log levels, feature flags, hostnames | Credentials: passwords, tokens, TLS keys |
| Stored in etcd | Plaintext | Plaintext too, unless you enable encryption at rest |
| In the API response | Plaintext | Base64-encoded (encoding, not encryption) |
| Access control | RBAC like any resource | RBAC, and you should actually use it |
| Size limit | 1 MiB | 1 MiB |
So when do you choose which? If losing the value would not hurt you, it is a ConfigMap. If leaking the value means rotating credentials, it is a Secret. The recipes above show why: Secrets get separate RBAC treatment, can be encrypted at rest with an EncryptionConfiguration, and can be synced from an external manager like Vault — none of which you need for a log level.
The one rule that never changes: do not put passwords in a ConfigMap just because it is easier to read. Everyone with get configmap access in that namespace can read it too.
Where to go next
Configuration is sorted, but config files and Secrets vanish when a pod does — your database cannot live like that. In Part 8: Storage we give pods disks that survive restarts with Volumes, PersistentVolumeClaims, and StorageClasses. For the full reference on everything you mounted today, the official ConfigMap documentation is the canonical source. The whole series lives at /series/kubernetes-cookbook.

