K8s Basics #6: ConfigMap and Secret — Splitting Out Configuration
#5 Service made it clear that having config and secret values baked into the manifest is awkward to operate. This post covers how to pull those out of the manifest body — ConfigMap and Secret. How to inject them, what’s different about secrets, and how changes get reflected back to the Pod.
This series is K8s Basics, 7 posts.
- #1 What is Kubernetes — why do we need a container orchestrator?
- #2 Local environments — minikube / kind / Docker Desktop k8s
- #3 kubectl and your first Pod
- #4 Deployment and ReplicaSet — declarative deploys and rolling updates
- #5 Service — ClusterIP / NodePort / LoadBalancer
- #6 ConfigMap and Secret — splitting out configuration ← this post
- #7 Namespaces and labels
By the end of this post, you’ll have the shape where the same Deployment manifest applies to dev / staging / prod unchanged. Values that differ across environments live in ConfigMap / Secret; the workload definition is one set of files.
The 12-factor line — store config in the environment #
The problem ConfigMap / Secret solves wasn’t invented by K8s. It’s a pattern long established in web ops, well before containers became standard. The most-quoted source is item III of the 12-factor app, which boils down to:
Store config in the environment.
“Config” here means values that vary between environments — DB hosts, external API keys, log levels, passwords. Before containers, this was solved with environment variables, files in /etc/, .env files, and so on. In the K8s era, ConfigMap (plain config values) and Secret (sensitive values) take that role.
Why split it out? Three lines:
- Change config without touching the image or code — leave the same container image alone and change behavior just by injecting different env vars. No new build, no new image tag.
- Multi-environment deploys — manifests for dev / staging / prod are mostly identical, with the differences pulled out into ConfigMap / Secret. The workload definition doesn’t get duplicated per environment.
- Keep secrets out of git — DB passwords, API tokens written in plain text in a manifest go straight to the git repo. Splitting them into a Secret means the manifest only references it; the real values arrive in the cluster through a separate path.
Those three drive nearly every operational decision. Let’s start with ConfigMap.
ConfigMap — a key-value bag for config #
ConfigMap is exactly what the name says — a K8s object holding a key-value bag of configuration. One manifest creates one.
apiVersion: v1
kind: ConfigMap
metadata:
name: web-config
data:
LOG_LEVEL: "info"
APP_GREETING: "hello from k8s"
app.conf: |
server {
listen 80;
location / {
return 200 "ok\n";
}
}apiVersion is the core group’s v1, same as Service. ConfigMap isn’t a workload controller, so it isn’t apps/v1.
The key field is data. Values come in two shapes:
- Short key-value (scalars) —
LOG_LEVEL: "info",APP_GREETING: "hello from k8s"— single-line values. Perfect for injecting straight into env vars. - Multi-line files — long text in YAML’s block scalar (
|). Theapp.confabove — nginx configs, an app’sconfig.yaml, a small SQL script — straight into a single key.
Imperative creation, briefly #
Besides writing manifests, you can create ConfigMaps imperatively too.
kubectl create configmap web-config \
--from-literal=LOG_LEVEL=info \
--from-literal=APP_GREETING="hello from k8s" \
--from-file=app.conf--from-literal is inline, --from-file reads from disk and registers as a key. Quick, but with one clear downside — the resulting ConfigMap doesn’t exist as a manifest anywhere. Whoever inherits the cluster sees the ConfigMap but can’t trace it back through git. In production it’s almost always kubectl apply -f; imperative is only for quick debugging or experiments.
Size limit — 1 MiB #
The total size of values a ConfigMap holds can’t exceed 1 MiB (mebibyte). This isn’t a K8s policy choice — it’s etcd’s per-object size limit. Small config files and env-var bundles fit easily. But cramming large static assets (model weights, big SQL schemas, browser bundles) into a ConfigMap is the wrong tool for the job. Those belong in separate storage (S3 / GCS, PV) and get pulled into the container from there.
Apply and look.
kubectl apply -f web-config.yamlconfigmap/web-config createdkubectl get cmNAME DATA AGE
kube-root-ca.crt 1 2d
web-config 3 10sThe columns — NAME / DATA / AGE. The number under DATA is the count of keys under data. With 3 keys (LOG_LEVEL, APP_GREETING, app.conf) it’s 3. kube-root-ca.crt is the cluster’s own ConfigMap, ignore it.
Three ways to inject a ConfigMap into a Pod #
Just creating a ConfigMap doesn’t tell a Pod about it. The manifest has to spell out how the Pod consumes the values. Three ways, and getting their differences straight is the most practical part of this post.
1. One key → one env var (env.valueFrom.configMapKeyRef)
#
The most explicit shape — map one ConfigMap key to one container env var.
spec:
containers:
- name: web
image: nginx:1.27
env:
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: web-config
key: LOG_LEVELenv is the field we touched briefly in #3. Usually it’s inline as value: "info", but valueFrom means pull the value from another object. configMapKeyRef.name is the ConfigMap name; key is the key inside it.
Pro: explicitness — the manifest spells out which env var came from which ConfigMap key. Con: length. With many env vars, the file gets long.
2. All keys → env vars at once (envFrom.configMapRef)
#
When you want every key in the ConfigMap to become an env var in one go.
spec:
containers:
- name: web
image: nginx:1.27
envFrom:
- configMapRef:
name: web-configThis injects every key in web-config as an env var on the container. LOG_LEVEL, APP_GREETING, app.conf all become env vars of the same name. Short and convenient, but with one caveat — the ConfigMap key name becomes the env var name verbatim. So if you intend to use envFrom, keep keys in UPPER_SNAKE_CASE. Keys with dots like app.conf make awkward env vars (shells don’t like dots in env var names). Keys like that belong in the volume mount in the next section.
3. Mount as files (volumes.configMap + volumeMounts)
#
Values that only make sense as files — like app.conf — should be placed inside the container as files. Mounting a ConfigMap as a volume creates one file per key.
spec:
containers:
- name: web
image: nginx:1.27
volumeMounts:
- name: app-conf
mountPath: /etc/myapp
volumes:
- name: app-conf
configMap:
name: web-config
items:
- key: app.conf
path: app.confTwo layers to read:
volumes— defined per Pod. Above, a volume namedapp-confwith contents fromweb-config’sapp.confkey. Withitems, only some keys are mounted; without, all keys become files.volumeMounts— defined per container, where to mount the volume in the container’s filesystem.mountPath: /etc/myappmakes the contents visible at/etc/myapp/app.confinside the container.
Which one to use when #
A small table of the three to make the decision faster.
| Injection shape | Best for | Description |
|---|---|---|
env.valueFrom.configMapKeyRef | One or two env vars | Explicit but verbose |
envFrom.configMapRef | A whole bundle of env vars | Short but needs key-name discipline |
volumes.configMap | A whole config file as a file | When the app reads from a file on disk |
The simple mental rule — few values: env. Whole bundle as env vars: envFrom. Has to be a file: volume.
Putting it all together — Deployment + ConfigMap, one cycle #
Roll the three shapes into a single manifest. Take the web Deployment from #4, inject one env var with env, the rest with envFrom, and mount app.conf as a volume.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.27
ports:
- containerPort: 80
env:
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: web-config
key: LOG_LEVEL
envFrom:
- configMapRef:
name: web-config
volumeMounts:
- name: app-conf
mountPath: /etc/myapp
volumes:
- name: app-conf
configMap:
name: web-config
items:
- key: app.conf
path: app.conf(Both env and envFrom are in here for illustration — in practice you’d usually pick one. If the same key is pulled from the same ConfigMap twice, the last definition wins.)
Apply and verify.
kubectl apply -f web-config.yaml -f web.yamlconfigmap/web-config unchanged
deployment.apps/web createdStep into a Pod and check both env vars and files.
kubectl exec -it deploy/web -- env | grep -E "LOG_LEVEL|APP_GREETING"LOG_LEVEL=info
APP_GREETING=hello from k8skubectl exec -it deploy/web -- cat /etc/myapp/app.confserver {
listen 80;
location / {
return 200 "ok\n";
}
}What we wrote in the ConfigMap shows up exactly as env vars and files inside the container. That’s the full cycle. The values themselves don’t appear in the manifest body — only references that say “pull this from the ConfigMap.”
Secret — separating out sensitive values #
If ConfigMap is for ordinary config, Secret is for values you must not put in plain text in the manifest body — passwords, tokens, certificates. The manifest shape is almost identical to ConfigMap.
apiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
stringData:
DB_USER: "myapp"
DB_PASSWORD: "s3cret-do-not-commit"apiVersion is v1 again. Two differences from ConfigMap:
type— Secret comes in several types, so it has atypefield. For an ordinary key-value bag,Opaque(the default).stringDatavsdata— two ways to write values in a Secret manifest.
data vs stringData — and the one-line truth about base64 #
The most important line of this post:
The name says Secret, but the default behavior is just base64 encoding. It is not encryption.
Run kubectl get secret db-secret -o yaml and the stringData is gone — only base64-encoded strings under data.
kubectl apply -f db-secret.yaml
kubectl get secret db-secret -o yamlapiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
data:
DB_USER: bXlhcHA=
DB_PASSWORD: czNjcmV0LWRvLW5vdC1jb21taXQ=bXlhcHA= and czNjcmV0LWRvLW5vdC1jb21taXQ= look unreadable, but base64 isn’t a security mechanism — it’s an encoding to move binary data through text. Decoding is one line.
kubectl get secret db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 -ds3cret-do-not-commitThe original value pops right out. So working with Secret objects is essentially working with plain-text secrets. Real protection has to come from elsewhere — covered briefly below.
The difference between data and stringData is just convenience.
data— values you’ve base64-encoded in advance. Awkward to write by hand.stringData— write the plaintext and K8s base64-encodes it for you. When humans write Secret manifests, this is what they almost always use.
Imperative creation works the same way as ConfigMap.
kubectl create secret generic db-secret \
--from-literal=DB_USER=myapp \
--from-literal=DB_PASSWORD=s3cret-do-not-commitSecret types, one line each #
Secret has a few standard types depending on usage. Four common ones:
| type | Use |
|---|---|
Opaque | Default. Arbitrary key-value bag |
kubernetes.io/dockerconfigjson | Private container registry credentials. Referenced by imagePullSecrets |
kubernetes.io/tls | TLS cert / key pair. Referenced at Ingress HTTPS termination |
kubernetes.io/service-account-token | ServiceAccount token. Comes up with RBAC |
Of these, the ones humans typically write manifests for are Opaque and kubernetes.io/tls. dockerconfigjson is usually created via the dedicated kubectl create secret docker-registry; service-account-token is mostly handled by K8s itself.
get secret columns
#
kubectl get secretNAME TYPE DATA AGE
db-secret Opaque 2 1mThe only difference from ConfigMap is the added TYPE column — NAME / TYPE / DATA / AGE. DATA is the key count.
Injecting Secret into a Pod #
Injection shapes for Secret are identical to ConfigMap — only the key names differ slightly. All three at once:
1. One key → one env var (env.valueFrom.secretKeyRef)
#
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: DB_PASSWORDConfigMap’s configMapKeyRef becomes secretKeyRef. That’s the entire change.
2. All keys → env vars at once (envFrom.secretRef)
#
envFrom:
- secretRef:
name: db-secretconfigMapRef → secretRef. Same advice on key names — UPPER_SNAKE_CASE.
3. Mount as files (volume.secret)
#
volumes:
- name: db-creds
secret:
secretName: db-secret
volumeMounts:
- name: db-creds
mountPath: /etc/db
readOnly: trueConfigMap volumes use configMap.name; Secret volumes use secret.secretName — the key name differs slightly. There’s also a difference in disk handling — Secret files mounted as a volume don’t land on the node’s disk in plain text. K8s places them in tmpfs (memory-backed filesystem) so they vanish on node reboot. ConfigMap doesn’t get this protection.
Handling real secrets safely #
Now that we’ve pinned down “Secret is just base64,” here’s a quick note on how production handles real secret values. Deep installs are out of scope; the goal is to know the names.
- etcd-level encryption — set
EncryptionConfigurationon the API server and connect KMS (AWS KMS, GCP KMS, etc.) so Secret values land in etcd encrypted. This is the first thing to enable when you operate the cluster yourself. - Sealed Secrets (Bitnami) — convert a Secret manifest into a
SealedSecretencrypted with the cluster’s public key. The encrypted manifest is safe to commit to git; once it lands in the cluster, a controller decrypts it back into a normal Secret. - External Secrets Operator — let an external secret store (Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) hold the real secret values, and the operator syncs them into K8s Secrets. The standard production answer.
Production clusters almost always run one or more of those alongside the basics. This series stops at the manifest shape and injection methods; deeper secret operations get a separate post in K8s Intermediate.
What happens when config changes? #
Now that we have ConfigMap / Secret, the natural follow-up question is — when I change a value, does it propagate to the Pod automatically? The answer depends on the injection shape. Worth pinning down because it’s a common stumble in production.
When injected as env vars — set once at start #
Values injected into env vars via env or envFrom are set once at Pod start, full stop. Modifying the ConfigMap after the fact doesn’t change the env vars on running Pods. The process’s environment block is set at start and frozen by the OS — that’s not a K8s limit but the nature of the process model.
To pick up new values in env vars, the Pod has to be replaced. The standard one-liner that triggers a controlled, gradual replacement:
kubectl rollout restart deployment/webThis works through the same mechanism as the rolling update from #4 — only here the spec stays the same and just the Pods get gradually replaced. The new Pods come up with the latest ConfigMap values as env vars.
(kubectl rollout restart is a standard command since 1.15+. Before that, the workaround was to slightly alter the Pod template via an arbitrary annotation; that workaround is rare now.)
When mounted as a volume — auto-updated #
ConfigMaps and Secrets mounted as volumes are synchronized periodically, so the files update automatically. After modifying a ConfigMap, file contents inside the container change within a minute or so. The lag is tied to kubelet’s sync interval (default 1 minute), so it isn’t instantaneous.
One caveat though — the change only matters if the app re-reads the file in code. A process like nginx that reloads its config on SIGHUP works; an app that reads its config once at startup won’t behave any differently when the file changes. A common pattern is a sidecar (e.g., configmap-reload) that watches for config changes and triggers a reload.
One table #
| Injection shape | Reflection of changes | Force a refresh |
|---|---|---|
env / envFrom | Not auto (set once at start) | kubectl rollout restart |
volume | Auto (~minute lag) | App’s own reload, or kubectl rollout restart |
The simple ops baseline — after changing config, run kubectl rollout restart once for a controlled rollover. Either way, env vars or volumes, the change definitely lands at that point.
Clean up #
Tear down today’s objects.
kubectl delete -f web.yaml
kubectl delete -f web-config.yaml
kubectl delete -f db-secret.yamldeployment.apps "web" deleted
configmap "web-config" deleted
secret "db-secret" deletedkubectl get deploy,cm,secret showing nothing puts you back at a clean slate. The kube-root-ca.crt ConfigMap and default-token-... Secret are objects K8s keeps for itself, so seeing them remain is normal.
Summary #
What this post pinned down:
- The pattern of pulling environment-specific values and secrets out of the manifest body is 12-factor’s “store config in the environment.” In K8s, the objects that play that role are ConfigMap and Secret.
- The ConfigMap manifest spine is
apiVersion: v1/kind: ConfigMap/data. Underdatayou can put short key-values (scalars) or multi-line files (|). Size limit is 1 MiB. - Three injection paths into a Pod —
env.valueFrom.configMapKeyRef(single key),envFrom.configMapRef(whole bundle),volumes.configMap(as files). Simple rule — few:env, whole bundle:envFrom, file required:volume. - Secret looks almost identical to ConfigMap with an added
typefield. The name is Secret, but the default behavior is just base64 encoding — not encryption. Real protection comes from a separate layer — etcd encryption, Sealed Secrets, External Secrets Operator. - Secret injection has the same three shapes —
secretKeyRef/envFrom.secretRef/volume.secret. For plaintext,stringDatain the manifest is the human-friendly path. - Env vars are populated once at Pod start — to apply changes, run
kubectl rollout restart. Volume mounts auto-refresh in ~minute increments, but only matter if the app re-reads the file.
Next — Namespaces and labels #
Even now, one thing is still awkward — every object we’ve created (Pod, Deployment, Service, ConfigMap, Secret) lives in the default namespace. If a single cluster needs to host multiple environments (dev / staging) or multiple teams’ workloads, this single space gets cramped fast. And the labels we’ve been bumping into since #4’s selector deserve a proper pass too.
#7 Namespaces and labels covers (1) how namespaces logically partition the cluster, (2) the syntax of labels and selectors, plus the common label conventions, and (3) ops tips for working with kubectl per namespace — wrapping the series by sorting the seven manifest kinds we’ve covered into clean spaces in one cluster.