This is the third post in our Kubernetes fundamentals series, building on Pods and Deployments and networking.
Baking configuration values directly into a container image means rebuilding the image every time a value needs to change — the same problem we covered for Terraform variables and Ansible variables earlier in this blog. Kubernetes' answer is two related objects: ConfigMaps for ordinary configuration, and Secrets for anything sensitive — though the actual difference between them is smaller than most people assume.
ConfigMaps: externalizing ordinary configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: "info"
MAX_CONNECTIONS: "100"
FEATURE_FLAG_NEW_UI: "true"apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: my-app:1.0
envFrom:
- configMapRef:
name: app-configenvFrom with configMapRef injects every key in the ConfigMap as an environment variable inside the container — LOG_LEVEL, MAX_CONNECTIONS, and FEATURE_FLAG_NEW_UI are all available to the application exactly as if they'd been set with a plain environment: block, without rebuilding the image to change any of them.
Secrets: structurally almost identical, meaningfully different in intent
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
data:
DB_PASSWORD: UzNjdXIzLUFjdHVhbC1QYXNzd29yZA== containers:
- name: web
image: my-app:1.0
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secretsThe value under data is base64-encoded, not encrypted. Base64 is an encoding, not a cipher — anyone with kubectl get access to that Secret can trivially recover the original value:
kubectl get secret app-secrets -o jsonpath='{.data.DB_PASSWORD}' | base64 -dS3cur3-Actual-Password
This is the single most common misunderstanding people carry about Kubernetes Secrets: the name implies protection the object doesn't actually provide by default. What a Secret does give you, that a ConfigMap doesn't, is: Kubernetes avoids logging its value in most standard output, and — critically, but only if you've configured it — encryption at rest in etcd (the cluster's backing datastore), which is not automatically enabled on every Kubernetes installation and needs to be verified, not assumed.
kubectl get secret app-secrets -o yamlIf that command, run by anyone with reasonable cluster access, immediately reveals a recoverable password — as it does by default — the real fix is usually reaching for a proper secrets-management integration rather than treating a raw Kubernetes Secret as sufficient on its own:
- External Secrets Operator, syncing values from AWS Secrets Manager, HashiCorp Vault, or a similar external store into Kubernetes Secrets automatically, so the actual secret value never has to be manually base64-encoded and committed anywhere.
- Sealed Secrets, which lets you commit an encrypted version of a Secret to git safely — decryptable only by a controller running inside the specific target cluster.
Either approach addresses the actual gap: encrypting the value before it's stored as a Kubernetes object, rather than trusting the object's own default protections. This is the same principle as Ansible Vault, covered earlier in this blog: the secrets-management tool exists specifically because the platform's own default handling of "a sensitive value" is not, by itself, sufficient protection.
Restricting who can actually read a Secret
Beyond encrypting values before they land in etcd, Kubernetes' own RBAC (Role-Based Access Control) is what should actually gate kubectl get secret in the first place:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: secret-reader
namespace: production
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["app-secrets"]
verbs: ["get"]A Role scoped this narrowly — naming the specific Secret, granting only get, in one specific namespace — means even a team member with broad access to the production namespace's other resources doesn't automatically get to read this particular Secret's value, unless a corresponding RoleBinding grants it to them specifically. Treating Secret read access as its own deliberate RBAC decision, rather than an implicit side effect of general namespace access, is worth doing before it matters, not after an audit finds it was never actually restricted.
Mounting configuration as files instead of environment variables
Not every application reads configuration from environment variables — some expect a config file at a specific path. Both ConfigMaps and Secrets support this via volume mounts:
containers:
- name: web
image: my-app:1.0
volumeMounts:
- name: config-volume
mountPath: /etc/app/config
volumes:
- name: config-volume
configMap:
name: app-configEach key in the ConfigMap becomes a separate file inside /etc/app/config (a file named LOG_LEVEL containing the text info, for example) — the right approach for an application expecting a real config file on disk, rather than environment variables it has to read individually.
A detail worth knowing: updates don't always restart anything
Update a ConfigMap that's mounted as a volume, and the file inside the running Pod does eventually update automatically (subject to a caching delay, typically under a minute) — but the application itself doesn't automatically reload it unless it's specifically written to watch that file for changes. An application reading environment variables from envFrom gets none of this: those values are set once, at container start, and a ConfigMap update has no effect at all on an already-running container using that mechanism — the Pod needs to be recreated (a rolling restart of the Deployment) to pick up the new values.
kubectl rollout restart deployment/webThis is the practical fix for the environment-variable case: rather than waiting for something to notice a ConfigMap changed, explicitly trigger a rolling restart, which recreates every Pod (gradually, per the rolling-update behavior covered in the first post in this series) with the current ConfigMap values freshly injected.
Immutable ConfigMaps: preventing accidental drift
By default, a ConfigMap can be edited in place at any time — genuinely convenient during development, and a real risk in production, where an unreviewed kubectl edit configmap against a live cluster can silently change application behavior with no corresponding change in version control. Marking a ConfigMap immutable closes that gap:
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-v2
data:
LOG_LEVEL: "info"
immutable: trueOnce created, an immutable ConfigMap can't be edited at all — only deleted and replaced with a new one (conventionally under a new, versioned name, like the -v2 suffix here), which naturally produces a change that has to go through whatever deployment process creates the new object, rather than an ad-hoc live edit. This is the same "make the source of truth the thing that's actually deployed, not something drifted from it silently" principle behind Terraform's warning against hand-editing managed resources, applied here to Kubernetes configuration objects directly.
Projected volumes: combining multiple sources into one mount
A Pod occasionally needs data from more than one ConfigMap or Secret presented together, in a single directory, rather than as separate mount points:
volumes:
- name: combined-config
projected:
sources:
- configMap:
name: app-config
- secret:
name: app-secretsA projected volume merges multiple ConfigMaps and Secrets into one logical volume, with each source's keys appearing as files in the same mounted directory — useful when an application expects all of its configuration files in a single directory regardless of which underlying Kubernetes object each one actually came from, avoiding the need to restructure application code around Kubernetes' own object boundaries.
Validating a ConfigMap's contents before it reaches a Pod
A malformed ConfigMap value (invalid JSON in a field an application expects to parse, a typo in a numeric setting) fails silently at kubectl apply time — Kubernetes has no concept of validating a ConfigMap's actual content, only its structure as a Kubernetes object. Catching this earlier means validating the source file before it's ever turned into a ConfigMap, the same "catch it before it reaches the cluster" instinct as terraform validate or a Dockerfile linter run in CI before a real deploy.
What to actually remember from this post
- ConfigMaps and Secrets are structurally almost identical — both inject key-value data as environment variables or mounted files.
- A Secret's
datais base64-encoded, not encrypted — anyone with reasonablekubectlaccess can trivially decode it, unless encryption-at-rest and access controls are deliberately configured. - For genuinely sensitive values, integrate a real secrets manager (External Secrets Operator, Sealed Secrets) rather than relying on a raw Secret object's default behavior.
- Scope RBAC access to Secrets deliberately, by name and namespace, rather than as an implicit side effect of broader access.
- Environment-variable-based config requires a Pod restart to pick up changes — mounted-file config updates eventually, but the application still needs to actually watch for that change to benefit from it;
kubectl rollout restartis the practical trigger either way.
Next in the series: Kubernetes Storage: Volumes, PVCs, and StorageClasses, where we cover how an application keeps data that needs to outlive any individual Pod.
