The same container image should run in dev, staging and production. What changes between them is configuration, database hosts, feature flags, credentials, and Kubernetes provides two objects to supply it at runtime: ConfigMaps for ordinary values, Secrets for sensitive ones.
Getting this split right is what makes an image promotable rather than environment-specific.
An image with DATABASE_URL baked in is a staging image or a production image, never both. Promoting a build then means rebuilding it, which means the artifact you tested is not the artifact you shipped, and rotating a credential means a rebuild and redeploy of everything that uses it.
Externalising configuration is the third factor of the twelve-factor methodology, and it is the difference between promoting a tested artifact and rebuilding a hopefully-identical one.
| ConfigMap | Secret | |
|---|---|---|
| For | Non-sensitive configuration | Passwords, tokens, certificates, keys |
| Stored as | Plain text in etcd | Base64 in the API, optionally encrypted at rest |
Shown by kubectl get -o yaml | Values in the clear | Base64, trivially decoded |
| Size limit | 1 MiB | 1 MiB |
| Separate RBAC | Yes | Yes (and it is the control that matters) |
The honest summary: a Secret is a ConfigMap with different defaults and its own RBAC surface. Base64 is encoding, not encryption, and anyone who can get secrets in a namespace can read every one of them.
What a Secret genuinely gives you is worth listing, because it is not nothing:
kubectl describe output and most log lines.tmpfs for mounted Secret volumes, so the value never touches node disk.What it does not give you is meaningful protection against someone with read access to the namespace. That is what external secret managers are for.
Both objects can be injected as environment variables or mounted as files. The choice is not cosmetic.
As environment variables:
env:
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: app-config
key: LOG_LEVEL
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: app-secret
key: DB_PASSWORDAs mounted files:
volumes:
- name: config
configMap:
name: app-config
containers:
- name: app
volumeMounts:
- name: config
mountPath: /etc/appThe difference that matters operationally:
Mounted volumes update live. Environment variables never do.
Change a ConfigMap consumed as a volume and the kubelet refreshes the file inside the running container within a minute or so. Change one consumed as an environment variable and the running pod keeps the old value until it is replaced, because environment variables are fixed at process start.
This catches people constantly: they update a ConfigMap, see no change, and conclude Kubernetes is broken. To pick up a changed environment variable you must restart the pods:
kubectl rollout restart deployment/payments-apiThere is one exception to the live-update rule. A volume mounted with subPath does not update. It is mounted once and stays frozen. subPath is how you place a single config file into a directory that already has other contents, so this trips up exactly the people trying to be tidy.
Setting immutable: true prevents any further change to the data:
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-v3
immutable: true
data:
LOG_LEVEL: infoTwo benefits. It removes a whole class of accident, nobody edits production config in place, and it measurably reduces API server load, because the kubelet stops watching immutable objects for changes.
The pattern is to version the name (app-config-v3) and update the Deployment to reference the new one, which also makes config changes visible in your rollout history and rollback-able like any other change.
For production, most teams keep the source of truth outside the cluster and sync it in:
The common thread is that no plaintext secret is ever committed to a repository, and rotation happens in one place rather than across every manifest.
One thing to do regardless: never commit a Secret manifest with real base64 data to Git. Base64 is not obfuscation, and Git never forgets.
| Sub-topic | What you will learn |
|---|---|
| ConfigMaps | Create ConfigMaps and inject config as env vars or files |
| Secrets | Store sensitive values, registry credentials and TLS material |
| Task | Command |
|---|---|
| Create a ConfigMap | kubectl create configmap <n> --from-literal=K=V |
| From a file | kubectl create configmap <n> --from-file=app.conf |
| Create a Secret | kubectl create secret generic <n> --from-literal=K=V |
| Read a Secret value | kubectl get secret <n> -o jsonpath='{.data.K}' | base64 -d |
| Registry credentials | kubectl create secret docker-registry regcred --docker-server=... --docker-username=... --docker-password=... |
| TLS Secret | kubectl create secret tls <n> --cert=tls.crt --key=tls.key |
| Pick up an env var change | kubectl rollout restart deployment/<n> |
subPath mounts never auto-update, which surprises people trying to place a single file neatly.immutable: true with versioned names: safer, and it lightens the API server.<MCQExercise exercise={{ id: 'k8s-config-env-no-update', title: 'A config change that did nothing', description: 'You update a ConfigMap consumed by pods as environment variables via envFrom. Minutes later the running pods still report the old value. What is happening?', options: [ { id: 'a', text: 'Environment variables are fixed when the process starts, so running pods keep the old value until they are replaced' }, { id: 'b', text: 'ConfigMap changes take up to an hour to propagate through the API server' }, { id: 'c', text: 'envFrom is deprecated and no longer reads updates' }, { id: 'd', text: 'The ConfigMap must be marked immutable before changes take effect' }, ], correctId: 'a', explanation: 'A process receives its environment once, at exec time, and nothing can change it afterwards. This is an operating system property, not a Kubernetes limitation. Mounted ConfigMap volumes do refresh live, which is why the same edit appears to work for file-based config and not for environment variables. Run kubectl rollout restart to pick it up.', wrongExplanations: { b: 'The ConfigMap object itself updates immediately. The delay is only in how consumers see it, and for volumes it is about a minute rather than an hour.', c: 'envFrom is current and widely used. It simply populates the environment at container start like any other env source.', d: 'immutable: true does the opposite. It forbids changing the data at all, and is used with versioned names rather than in-place edits.', }, }} />
<MCQExercise exercise={{ id: 'k8s-config-secret-security', title: 'What a Secret actually protects', description: 'A team moves database passwords from a ConfigMap into a Secret. What security improvement have they actually gained?', options: [ { id: 'a', text: 'A separate RBAC resource and the option of etcd encryption at rest, but the values are still readable by anyone who can get secrets' }, { id: 'b', text: 'The values are now encrypted and cannot be read without a decryption key' }, { id: 'c', text: 'Nothing at all; Secrets and ConfigMaps are identical in every respect' }, { id: 'd', text: 'The values are automatically rotated by Kubernetes on a schedule' }, ], correctId: 'a', explanation: 'The meaningful gains are authorisation and storage. Secret is its own RBAC resource, so read access can be granted separately from ConfigMap access, and clusters can encrypt Secrets at rest in etcd. The data itself is only base64-encoded, so a principal with get secrets in the namespace can decode it in one command.', wrongExplanations: { b: 'Base64 is an encoding, not a cipher, kubectl get secret piped through base64 -d reveals the value. Encryption at rest protects etcd on disk, not API reads.', c: 'They are close, but not identical. Separate RBAC, exclusion from describe output, optional encryption at rest and tmpfs-backed mounts are real differences.', d: 'Kubernetes has no built-in rotation. That comes from external tooling such as the External Secrets Operator or Vault, backed by a secret manager that rotates.', }, }} />
Because it consumes the value as an environment variable. A process gets its environment once at start-up and it cannot be changed afterwards, so the pod keeps the old value until it is replaced. ConfigMaps mounted as volumes do update in place, typically within a minute. To apply an environment variable change, run kubectl rollout restart deployment/<name>, which triggers a normal rolling update with no downtime. Note that volumes mounted with subPath also never update, which is a separate and easily missed cause of the same symptom.
Not by default in the sense people usually mean. Values are base64-encoded in the API, which is trivially reversible. Kubernetes can encrypt Secrets at rest in etcd, but that must be configured explicitly, on managed clusters it generally means enabling envelope encryption with a cloud KMS key. Even then, anyone with get secrets permission in the namespace reads the plaintext through the API. RBAC is the real control, and for high-value credentials an external manager such as Vault or AWS Secrets Manager, synced in via the External Secrets Operator, is the right answer.
Never commit a Secret manifest containing real base64 data. It is not obfuscated and Git history is permanent. The two established approaches are Sealed Secrets, which encrypts a Secret with a cluster-held key so the encrypted file is safe to commit and only the in-cluster controller can decrypt it, and the External Secrets Operator, where Git holds only a reference to a secret stored in AWS Secrets Manager, Parameter Store or Vault. Both keep plaintext out of the repository while staying compatible with GitOps.
Yes, 1 MiB, imposed by etcd. It is more than enough for configuration and too little for data. If you are approaching it, the content probably belongs somewhere else: a file fetched at start-up from object storage, a volume, or an init container that materialises it. Very large ConfigMaps also put real pressure on the API server, since every kubelet consuming one watches it for changes unless it is marked immutable.
Mounted files when the value may change while the pod runs, when the application expects a config file on disk, or when the value is large or structured. Environment variables when the value is small, static for the pod's lifetime, and the application reads its configuration from the environment. A practical default is environment variables for simple scalars and volumes for anything file-shaped, while remembering that only volumes refresh without a restart.
Create a Kubernetes ConfigMap with kubectl or YAML, use it as env vars with envFrom or as mounted files, and find out why a ConfigMap change did not apply.
Create Kubernetes Secrets, mount them as env vars or files, use imagePullSecrets and TLS secrets, and learn why base64 is not encryption.
Compare Kubernetes storage options from emptyDir to PersistentVolumes. Learn PVCs, ReadWriteOnce vs ReadWriteMany, reclaim policies and CSI drivers.