Env / ConfigMap / Secret Tasks — Beginner → Advanced¶
A hands-on ladder for learning how configuration reaches a container, using
config.yaml in this folder. Do these AFTER deployment-tasks.md — you need
something that rolls before "how do I roll out a config change" means anything.
Track A module 3b. Do before config-storage-tasks.md, which picks up the same
ConfigMaps and Secrets from the storage/volume side (symlink swaps, subPath,
projected volumes, PV/PVC). This file is the developer side: what my process
sees in env, and what happens when I change it.
The one idea: env vars are resolved once, at container start, and never change. Mounted files do change. Every configuration incident you will ever have is downstream of that one sentence.
Setup: kubectl apply -f config.yaml (creates namespace env-lab).
All commands assume -n env-lab.
Level 0 — Orientation¶
kubectl explain pod.spec.containers.env— a list of{name, value}, not a map. Order matters (Task 1.3).kubectl explain pod.spec.containers.env.valueFrom— the four sources:configMapKeyRef,secretKeyRef,fieldRef,resourceFieldRef.kubectl explain pod.spec.containers.envFrom— bulk import of a whole ConfigMap or Secret.- The split to hold in your head: ConfigMap and Secret are just API objects.
env/envFromand volume mounts are two independent ways of consuming them, with completely different runtime behaviour.
ConfigMap ─┬─ envFrom / configMapKeyRef ──▶ process environment (frozen)
└─ volumes[].configMap ──▶ files in the container (live)
Secret ─┴─ same two paths, plus tmpfs and different RBAC conventions
Level 1 — Beginner: plain env vars¶
- Task 1.1 — Apply and look
- Do:
kubectl apply -f config.yamlthenkubectl exec -n env-lab env-basics -- env | sort -
Learn: you see your own variables, plus
HOSTNAME, plus variables the image itself declared (PATH), plusKUBERNETES_SERVICE_HOSTand friends that nobody asked for (Task 1.6). -
Task 1.2 — Numbers must be quoted
- Do: edit
config.yaml, changevalue: "8080"tovalue: 8080, apply. - Verify: rejected —
cannot unmarshal number into Go struct field EnvVar.value of type string. -
Learn:
env.valueis a string, always. Same for every value in a ConfigMap'sdata. YAML's helpfulness (unquotedtrue,25,8.0) is a constant source of this error. -
Task 1.3 —
$(VAR)expansion, and where it breaks - Do:
kubectl exec -n env-lab env-basics -- env | grep -E 'ADDRESS|LITERAL|BROKEN_REF' - Verify:
ADDRESS=checkout:8080,LITERAL=$(PORT), andBROKEN_REF=$(NOT_YET)— unexpanded. -
Learn: Kubernetes expands
$(VAR)using only variables defined earlier in the sameenvlist. A forward reference is silently left as literal text.$$escapes the expansion. Variables fromenvFromare not available to expansion at all. -
Task 1.4 — Duplicates: last one wins
- Do:
kubectl exec -n env-lab env-basics -- printenv DUP - Verify:
second. -
Learn: the API server does not reject duplicate names. In a Helm chart where a base template and an override both set
LOG_LEVEL, position in the list decides — not which one you meant. -
Task 1.5 — Expansion works in
commandandargstoo - Do: add
command: ["sh","-c","echo $(SERVICE_NAME)"]to a scratch pod. -
Learn: Kubernetes substitutes
$(SERVICE_NAME)before the shell runs, so the shell never sees it. If the variable is undefined, the literal$(SERVICE_NAME)reachessh, which interprets it as command substitution and tries to runSERVICE_NAMEas a program. Confusing failure, memorable once. -
Task 1.6 — Service link variables
- Do:
kubectl exec -n env-lab env-basics -- env | grep KUBERNETES_ - Then:
kubectl exec -n env-lab deploy/configured-app -- env | grep -c KUBERNETES_ - Verify: the Deployment (which sets
enableServiceLinks: false) has none. -
Learn: by default the kubelet injects
<SVCNAME>_SERVICE_HOST/<SVCNAME>_SERVICE_PORTfor every Service that existed in the namespace before the pod started. It is a Docker-links relic. It bloats large namespaces and can collide with your own names (EC-6). Turn it off. -
Task 1.7 — Env is per-container
- Do:
kubectl get pod env-basics -n env-lab -o jsonpath='{.spec.containers[*].env[*].name}' - Learn:
envlives on the container, not the pod. Sidecars and initContainers get nothing automatically — every container repeats its own block. (Contrast withvolumes, which are pod-scoped.)
Level 2 — ConfigMaps as configuration¶
- Task 2.1 — Create one three ways
- Verify:
--from-filemakes one key whose value is the whole file;--from-env-filemakes one key per line. -
Learn: same input, two completely different objects.
--dry-run=client -o yamlis how you check before you commit — use it constantly. -
Task 2.2 —
envFromvsconfigMapKeyRef - Do:
kubectl exec -n env-lab env-from-config -- env | sort - Verify:
LOG_LEVEL(bulk, viaenvFrom) andAPP_LOG_LEVEL(explicit, renamed) are both present. -
Learn:
envFromis convenient and opaque — you cannot tell what variables a container has by reading its YAML.configMapKeyRefis verbose and greppable. UseenvFromfor a map you own end to end, explicit refs for anything a reviewer needs to see. -
Task 2.3 — Precedence
- Do:
kubectl exec -n env-lab env-from-config -- printenv REGION - Verify:
overridden-by-env, noteu-central-1. -
Learn:
envFromis applied first, thenenvoverrides it. WithinenvFrom, later entries in the list override earlier ones. -
Task 2.4 —
prefix - Do:
kubectl exec -n env-lab env-from-config -- env | grep FF_ - Verify:
FF_FEATURE_A=true. -
Learn:
prefixnamespaces an imported map. The only sane way toenvFromtwo maps that might share a key. -
Task 2.5 — Invalid names disappear
- Do:
kubectl exec -n env-lab env-from-config -- env | grep -i beta→ nothing. - Do:
kubectl describe pod env-from-config -n env-lab | grep -A3 Events - Verify: an event with reason
InvalidEnvironmentVariableNames. -
Learn:
feature.betais a legal ConfigMap key and an illegal env var name. The kubelet skips it and starts the container anyway. Your flag is just missing. Keys destined for env must match[A-Za-z_][A-Za-z0-9_]*. -
Task 2.6 — THE BIG ONE: env is a snapshot
- Do:
- Verify: unchanged, forever.
-
Learn: env is resolved once, at container start. There is no mechanism to update it and nothing will restart the pod for you.
-
Task 2.7 — …but the mounted file DOES change
- Do: in the same pod,
- Verify: it changes, after up to ~60–90s (kubelet sync period + cache TTL).
-
Learn: mounted = eventually updated, env = never updated. One ConfigMap, two behaviours, decided entirely by how you consumed it. Your app still has to re-read the file — Kubernetes changes the bytes, not your process's understanding of them.
-
Task 2.8 —
kubectl set env - Learn: each of these edits the pod template → new revision → rolling update.
Great for debugging, and exactly the imperative-vs-declarative drift trap from
replica-tasks.mdEC-6. The file is still the source of truth.
Level 3 — Secrets¶
- Task 3.1 — base64 is not encryption
-
Learn: anyone with
get secretin this namespace has the plaintext. Secrets differ from ConfigMaps in RBAC convention, tmpfs mounting, and at-rest encryption if the cluster enables it — not in the object itself. -
Task 3.2 —
stringDatais write-only - Do:
kubectl get secret app-secret -n env-lab -o yaml - Verify: no
stringDatafield; onlydata, base64-encoded. -
Learn:
stringDatais a convenience the API server consumes at write time. Handy:kubectl create secret generic x --from-literal=k=v --dry-run=client -o yaml. -
Task 3.3 — Both consumption paths at once
- Do:
kubectl logs -n env-lab env-secret - Verify: the value appears via
$DB_PASSWORD, via/etc/secret/DB_PASSWORD, anddfreports tmpfs. -
Learn: the file form is never written to node disk, and it counts against the container's memory (
resources-tasks.mdEC-8). -
Task 3.4 — Watch a secret leak
-
Learn: every child process inherits the whole environment; crash handlers and logging frameworks routinely dump it;
/proc/<pid>/environis readable inside the container. A mounted file has none of those properties. -
Task 3.5 — Rotation
- Do: patch
app-secret, then re-check the env var and the file, as in 2.6/2.7. - Verify: env frozen, file updated (~60–90s).
-
Learn: a rotated secret consumed as env is still the old secret. This is the outage: cert-manager or Vault rotates a credential, every pod keeps the expired one until something unrelated restarts them, and then it fails at 3am.
-
Task 3.6 — Immutable
- Do: add
immutable: trueto a copy of the secret, then try to edit it. - Learn: rejected. It also drops the kubelet's watch on that object, a real
scale win. Roll by creating a new name — which is what
kustomize'ssecretGeneratorhash suffix does for you.
Level 4 — The downward API¶
- Task 4.1 — Identity
- Do:
kubectl logs -n env-lab env-downward - Verify:
POD_NAME,POD_NAMESPACE,POD_IP,NODE_NAME,SA_NAME. -
Learn: this is how a process learns its own coordinates for log tagging, metrics labels, leader election and peer discovery. No API call, no ServiceAccount permissions needed.
-
Task 4.2 — Labels are NOT available as env vars
- Do: try adding
- Verify: rejected by the API server.
-
Learn: env
fieldRefaccepts only a short whitelist (metadata.name,metadata.namespace,metadata.uid,spec.nodeName,spec.serviceAccountName,status.hostIP,status.podIP,status.podIPs). Labels and annotations reach a container only via a downwardAPI volume — already mounted at/etc/podinfoin this pod. -
Task 4.3 — …and the volume form updates
- Do:
- Verify: the file reflects the new label.
-
Learn: same asymmetry as Task 2.6/2.7, now for pod metadata. Volume = live, env = frozen. Third time you have seen this; that is deliberate.
-
Task 4.4 —
resourceFieldRef: the container's own limits - Do:
kubectl exec -n env-lab env-downward -- env | grep -E 'CPU_|MEM_' - Verify:
CPU_LIMIT_MILLI=200,MEM_LIMIT_MI=64,CPU_REQUEST_MILLI=50. -
Learn: from inside a container,
/proc/cpuinfoandnprocshow the node's CPUs, not your cgroup quota. Runtimes that size thread pools from that number (older JVMs, Go's defaultGOMAXPROCS, anything callingnproc) will spawn 64 workers inside a 200m limit and throttle themselves into the ground (resources-tasks.md).resourceFieldRefis the portable fix. -
Task 4.5 — The dangerous default
- Do: create a pod using
resourceFieldRef: limits.cpuwith nolimitsset. - Verify: it does not fail — the value becomes the node's allocatable CPU.
- Learn: a silent 64× oversizing on a big node. Always pair
resourceFieldRefwith an actual limit.
Level 5 — Advanced: rolling out a config change¶
- Task 5.1 — Prove nothing happens
- Do:
kubectl patch cm app-config -n env-lab --type merge -p '{"data":{"REGION":"us-east-1"}}'thenkubectl get pods -n env-lab -wfor a minute. - Verify: no restarts, no new ReplicaSet, no rollout.
-
Learn: a Deployment watches its pod template, and a ConfigMap change does not touch the template. Kubernetes has no built-in "restart on config change."
-
Task 5.2 —
kubectl rollout restart - Do:
kubectl rollout restart deploy/configured-app -n env-lab - Do:
kubectl get deploy configured-app -n env-lab -o jsonpath='{.spec.template.metadata.annotations}' - Verify: a
kubectl.kubernetes.io/restartedAtannotation appeared. -
Learn: it works by changing the template, which changes the
pod-template-hash, which creates a new ReplicaSet — a normal rolling update (replica-tasks.mdEC-2 explains that hash). Nothing magic, and nothing automatic. -
Task 5.3 — The checksum annotation
- Do:
- Verify: a rollout starts.
-
Learn: this is the idiomatic pattern, and it is what Helm's
sha256sumannotation does. Because the checksum is derived from the ConfigMap, a config-only commit produces a template change automatically. -
Task 5.4 — The other idiom: hashed names
-
Learn:
kustomize'sconfigMapGeneratorappends a content hash to the name (app-config-7f9c2b) and rewrites references. The template changes because the name changed. Bonus: rollback actually works, because the old ConfigMap still exists. With the checksum approach,rollout undorestores the old pod template pointing at a ConfigMap whose content is already the new one. -
Task 5.5 — Decide per value
- Learn, and write it down for your own services:
| Value | Consume as | Why |
|---|---|---|
| service name, region, feature flags at deploy time |
env| never changes within a pod's life | | log level you want to flip live | mounted file + app watches it | env cannot change | | DB password, API key | mounted file (or env if you must) | rotation + leak surface | | TLS cert | mounted directory, neversubPath| rotates;subPathfreezes it | | own cpu/memory limit |resourceFieldRef|nproclies inside a container | | pod name / IP |fieldRef| free, no API call | | labels/annotations | downwardAPI volume | not available as env at all |
Level 6 — Edge Cases & Production Nuances¶
Same format as replica-tasks.md Level 6: trap → diagnose → fix/rule.
EC-1 — CreateContainerConfigError (and why there are no logs)¶
- Trap: the pod is scheduled, never starts,
kubectl logssayscontainer "app" in pod ... is waiting to start. - Reproduce: it is already running —
kubectl describe pod env-missing -n env-lab. - Diagnose: the events say
Error: couldn't find key DOES_NOT_EXIST in ConfigMap env-lab/app-config. - Why: a missing ConfigMap/Secret or a missing key referenced without
optional: trueblocks container creation. The kubelet retries forever. - Fix/rule:
CreateContainerConfigError= env/config reference problem, always. Compare the two shapes: - env reference broken →
CreateContainerConfigError - volume reference broken →
ContainerCreating+FailedMountBoth are invisible in logs, because no container ever ran. Read events first.
EC-2 — The config changed and nothing happened¶
- Trap: you patched the ConfigMap, confirmed the new value with
kubectl get cm, and the app still behaves the old way. - Why: one of three, in order of likelihood — consumed as env (frozen),
mounted with
subPath(frozen,config-storage-tasks.mdEC-7), or the app read the file once at boot and never again. - Fix:
kubectl rollout restart, or the checksum annotation (Task 5.3) so it happens without you. - Rule: "I changed the ConfigMap" is never sufficient. Ask how it is consumed, then whether the process re-reads it.
EC-3 — A rotated secret keeps working, then suddenly does not¶
- Trap: credentials rotate on schedule; everything is fine for days; a node drain replaces some pods and half your fleet starts failing auth — or the reverse, the old credential expires and everything fails at once.
- Why: secrets consumed as env are pinned to whenever each pod last started. Your fleet is running a mix of credential vintages and you cannot tell which.
- Diagnose:
kubectl get pods -n NS --sort-by=.status.startTimenext to the secret'smetadata.resourceVersion/ last-modified time. - Rule: anything with an expiry gets mounted, and the app reloads it. Env vars are for values that outlive the pod.
EC-4 — envFrom silently dropped half my keys¶
- Trap: a ConfigMap with
feature.beta,feature-gamma,1st_flag— none of them appear in the container. - Diagnose:
kubectl describe pod→ reasonInvalidEnvironmentVariableNames. - Rule: ConfigMap keys allow
.and-; env var names do not. Keys used withenvFrommust match[A-Za-z_][A-Za-z0-9_]*. If you need dotted keys (Spring, Java properties), consume the map as a file, not as env.
EC-5 — Precedence surprises in a Helm chart¶
- Trap: the chart's
envFrompulls a shared platform ConfigMap; your values file sets the same variable inenv; a teammate swears the ConfigMap is wrong. - Rule:
envFromfirst, thenenvwins; within a list, later wins; duplicates are legal. When in doubt do not reason about it —kubectl exec POD -- printenv NAMEis the ground truth, andkubectl set env deploy/x --listshows the resolved template.
EC-6 — A Service name collided with my config¶
- Trap: your app reads
DB_PORTexpecting5432and getstcp://10.96.0.42:5432. - Why: a Service named
dbexists in the namespace, and the kubelet injects Docker-link-styleDB_PORT,DB_PORT_5432_TCP_ADDR,DB_SERVICE_HOSTvariables for every Service created before your pod. Restart order decides whether you get them — so it works locally and breaks in staging. - Fix:
enableServiceLinks: falsein the pod spec. - Rule: set it on every workload. In a namespace with hundreds of Services it is also a measurable memory and startup cost per container.
EC-7 — The 1 MiB wall¶
- Trap:
kubectl applyfails withConfigMap ... is invalid: too long: must have at most 1048576 bytes, or a pod fails to exec withargument list too long. - Why: ConfigMaps and Secrets are etcd objects and are capped at ~1 MiB. The process environment has its own OS-level ceiling as well.
- Rule: config objects hold config, not data. TLS bundles, ML model files and seed datasets belong in a volume or an image layer. If a ConfigMap is near the limit, the design is already wrong.
EC-8 — Cross-namespace references do not exist¶
- Trap: a pod in
stagingreferencing a Secret insharedsits inCreateContainerConfigErrorwith "not found", and the Secret is right there. - Rule:
configMapKeyRef/secretKeyRef/envFromare namespace-local, always. There is no cross-namespace form and there never will be — it would be an RBAC hole. Replicate the object (External Secrets Operator, a reflector controller, or your CD pipeline).
EC-9 — optional: true hides the problem instead of solving it¶
- Trap: someone "fixed" a
CreateContainerConfigErrorby addingoptional: true. The pod now starts happily — with noDB_PASSWORD— and fails much later, deeper, with a worse error. - Rule:
optionalis correct for genuinely optional tunables and for maps that legitimately may not exist yet. For anything the app requires, a pod that refuses to start is the better failure: it is loud, immediate, and stops the rollout instead of taking traffic.
Cheat sheet¶
kubectl create cm NAME --from-literal=K=V --from-file=f --from-env-file=f --dry-run=client -o yaml
kubectl create secret generic NAME --from-literal=K=V --dry-run=client -o yaml
kubectl get secret S -o jsonpath='{.data.KEY}' | base64 -d
kubectl exec POD -- env | sort # ground truth for a running pod
kubectl exec POD -- printenv NAME
kubectl set env deploy/D --list # resolved template, incl. envFrom
kubectl set env deploy/D FOO=bar # -> new revision -> rollout
kubectl set env deploy/D --from=cm/app-config --prefix=APP_
kubectl set env deploy/D FOO- # trailing dash = remove
kubectl describe pod POD | grep -A15 Events # CreateContainerConfigError lives here
kubectl rollout restart deploy/D # the only supported "reload config"
kubectl patch cm app-config --type merge -p '{"data":{"LOG_LEVEL":"error"}}'
Mental model to lock in¶
env= a photograph taken at exec(). Mounted file = a live feed. Never updates vs. updates in ~60–90s. This one asymmetry explains this entire file.- Kubernetes never restarts a pod because config changed. You trigger it, by
changing the pod template —
rollout restart, a checksum annotation, or a hashed ConfigMap name. envFromthenenv; later wins; duplicates are legal. Verify withprintenv, do not reason about it.- Broken env ref =
CreateContainerConfigError. Broken volume ref =ContainerCreating+FailedMount. Neither produces logs. - ConfigMap keys ⊅ env var names — dots and dashes are dropped silently.
- Secrets are base64, not encrypted, and as env they leak into
describe,/proc/<pid>/environ, child processes and crash dumps. - Everything is namespace-local, and capped at ~1 MiB.
- From inside a container,
nproclies.resourceFieldReftells the truth — but only if you actually set a limit.
┌──────────────── ConfigMap / Secret (API object) ───────────┐
│ │
envFrom ──────┤ ├────── volumes[]
configMapKeyRef │ │ configMap/secret
secretKeyRef ▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ process environment │ │ files in the container │
│ resolved once at exec() │ │ kubelet re-syncs ~60-90s│
│ NEVER changes │ │ (subPath freezes it) │
└──────────────────────────┘ └──────────────────────────┘
│ │
└──── to change it: change the POD TEMPLATE ──▶ rollout ──────┘
(app must still re-read)