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.
Top comments (0)