Kubernetes ConfigMaps and Secrets: Managing Configuration
How to inject config into Kubernetes the twelve-factor way: ConfigMaps vs Secrets, env vars vs mounted files, the reload gotcha that trips everyone, and why a Secret is only base64, not encrypted.
The single most important thing to understand about configuration in Kubernetes is that it lives outside your image. You build one immutable image, and the same image runs in dev, staging, and prod - what changes between environments is the config you inject at runtime, not the artifact. This is the twelve-factor idea, and Kubernetes gives you two objects to do it with: ConfigMap for non-sensitive values and Secret for sensitive ones. They look almost identical on the surface, which hides some real tradeoffs and one gotcha that catches nearly everyone in production. This guide covers how to create them, the two ways to inject them, why changing a ConfigMap often does nothing to a running pod, and the uncomfortable truth about what a Secret actually protects.
Why config never goes in the image
The rule is blunt: if a value differs between environments, or you would ever want to change it without a rebuild, it does not belong in the image. Baking a database URL, a log level, or a feature flag into the container means you now have a different image per environment, and the thing you tested in staging is no longer the thing you ship to prod. That defeats the entire point of an immutable artifact.
The discipline is to build once and promote the same image through environments, flipping behavior with injected config. Dev points at a local database, prod points at the managed one, staging turns on a debug flag - all the same image, different ConfigMaps and Secrets. When an incident happens, "which image is running?" has one answer everywhere, and the only variable is config you can read and diff. This is worth internalizing before you touch any YAML: ConfigMaps and Secrets exist so the image stays constant and the environment does not leak into it.
ConfigMaps: creating them
A ConfigMap is just a named bag of key-value pairs stored in the cluster. You can create one imperatively from literals, from files, or from an env-file, which is handy for quick tests and scripting.
# From literal key=value pairs
kubectl create configmap web-config \
--from-literal=log_level=info \
--from-literal=max_connections=100
# From a file - key becomes the filename, value becomes the file contents
kubectl create configmap nginx-config --from-file=nginx.conf
# From a whole directory - one key per file
kubectl create configmap app-config --from-file=./config/
# From an env-file - each LINE becomes a separate key
kubectl create configmap web-config --from-env-file=app.env
Note the difference between --from-file and --from-env-file: --from-file=app.env creates a single key app.env whose value is the entire file, while --from-env-file=app.env parses the file and creates one key per KEY=value line. That distinction matters a lot once you start mounting them.
In practice you write the ConfigMap as YAML and kubectl apply it, so it lives in git alongside the rest of your manifests:
apiVersion: v1
kind: ConfigMap
metadata:
name: web-config
data:
log_level: "info"
max_connections: "100"
# A multi-line value - useful for whole config files
app.properties: |
server.port=8080
cache.ttl=300
The data map holds UTF-8 strings. (There is also a binaryData field for binary blobs, base64-encoded, but you rarely need it.) Everything under data is plain text, readable by anyone with kubectl get configmap -o yaml access - which is exactly why anything sensitive goes in a Secret instead.
The two injection styles, and their tradeoffs
There are two ways to get a ConfigMap into a container, and choosing between them is a real decision, not a stylistic one.
As environment variables. You pull individual keys into named env vars with configMapKeyRef, or dump every key from a ConfigMap into the environment with envFrom.
spec:
containers:
- name: web
image: myapp:1.5.0
env:
# One key, mapped to a specific env var name
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: web-config
key: log_level
envFrom:
# Every key in the ConfigMap becomes an env var (key name = var name)
- configMapRef:
name: web-config
As mounted files. You mount the ConfigMap as a volume, and each key becomes a file whose contents are the value. This is how you inject whole config files (an nginx.conf, an application.yaml) that the app reads from disk.
spec:
containers:
- name: web
image: myapp:1.5.0
volumeMounts:
- name: config
mountPath: /etc/web
readOnly: true
volumes:
- name: config
configMap:
name: web-config
That mount produces /etc/web/log_level, /etc/web/max_connections, and /etc/web/app.properties, each a file. You can also project a single key to a specific path with items if you do not want the whole map.
The tradeoffs:
- Env vars are simple and universal - every language reads
os.environ- but they are snapshotted at container start. They are also flat (no structure), visible to any process in the container, and, as we will see, cannot change without a restart.envFromis convenient but silently pulls in every key, so a stray addition to the ConfigMap becomes a new env var you did not intend. - Mounted files carry structure (a real YAML/INI/JSON file), keep large config out of the environment, and - critically - Kubernetes updates them in place when the ConfigMap changes. The cost is that your app must read from a path, and it must be willing to re-read that file to pick up changes.
Rule of thumb: small scalar values (log level, ports, flags) as env vars; whole config files or anything you might want to update live, as mounted files.
The reload problem, the one that bites everyone
Here is the gotcha that shows up in real incidents constantly: changing a ConfigMap does not update a running pod's environment variables. Env vars are read once, when the container process starts. You can kubectl edit configmap web-config, change log_level to debug, and every running pod keeps running with the old value forever. Nothing tells you it did not take effect - the config is "updated," the pods are healthy, and the behavior is stale. People burn hours here wondering why their change did nothing.
Mounted files are better but not automatic. Kubernetes does propagate a changed ConfigMap to the mounted files (via the kubelet's periodic sync - typically within a minute or so, longer if the kubelet cache TTL is high). But updating the file on disk is not the same as your application noticing. Unless your app watches the file for changes or re-reads it periodically, it will keep using whatever it loaded at startup. So even with volume mounts, "the file changed" and "the app reloaded" are two different events, and only the first is Kubernetes' job.
The practical pattern is to stop relying on live reload and instead force a rolling restart when config changes:
kubectl rollout restart deploy/web
That triggers a normal rolling update - new pods come up reading the new config, old pods drain out - with zero downtime and the same safety as a code deploy. It works for both env-var and file-mounted config, and it makes the config change a deliberate, observable rollout instead of a silent partial update.
The more robust version, especially in GitOps setups, is a config hash annotation. You compute a hash of the ConfigMap contents and stamp it onto the pod template. When the ConfigMap changes, the hash changes, the pod template changes, and the Deployment controller automatically performs a rolling update - because as far as it is concerned, the desired state of the pods changed.
spec:
template:
metadata:
annotations:
# Change this when the ConfigMap changes to force a new rollout.
# Tools like Helm/Kustomize compute it for you.
configHash: "sha256-9f2c1a..."
Helm users do this with a checksum/config annotation templated from the ConfigMap; Kustomize does it automatically via configMapGenerator, which appends a content hash to the ConfigMap name so any change produces a new object and a new rollout. Either way, the principle is the same: tie the pod's identity to the config's content, so a config change is a deploy, not a mystery.
Immutable ConfigMaps and Secrets
By default a ConfigMap or Secret can be edited in place. You can also mark one immutable, which forbids any change to data after creation - to alter it, you delete and recreate (usually under a new name).
apiVersion: v1
kind: ConfigMap
metadata:
name: web-config-v3
immutable: true
data:
log_level: "info"
This buys two things. Safety: an immutable config cannot be accidentally mutated out from under running pods, which pairs naturally with the hash-name pattern above (each version is a distinct, frozen object). Performance: the kubelet normally watches every mounted ConfigMap and Secret for changes, and at scale (thousands of pods) that watch traffic is real load on the API server. Immutable objects need no watch, so the kubelet stops polling them, cutting API-server load significantly on large clusters. If you are already doing versioned config with hashed names, making them immutable is close to free upside.
Secrets: the truth about base64
A Secret is structurally almost identical to a ConfigMap - a named bag of key-value data - and it is consumed the same two ways. The difference people think they are getting is encryption. They are not.
A Secret is only base64-encoded, not encrypted. Base64 is an encoding, not a cipher; echo <value> | base64 -d reverses it instantly. Anyone who can read the Secret object (kubectl get secret db -o yaml) can read your password. Storing something in a Secret rather than a ConfigMap gains you separation and slightly different handling (it is redacted in some outputs, it is a distinct RBAC-controllable resource type), not confidentiality.
apiVersion: v1
kind: Secret
metadata:
name: web-secrets
type: Opaque
data:
# base64 of "s3cr3t" - trivially reversible, NOT encrypted
db_password: czNjcjN0
stringData:
# Or use stringData to write plaintext; Kubernetes encodes it for you
api_key: "plaintext-here"
What actually protects a Secret is layered and none of it is on by default:
- Encryption at rest for etcd. Everything, including Secrets, lives in etcd. Enable an
EncryptionConfiguration(ideally KMS-backed, not the weakaesgcmlocal key) so the Secret is ciphertext on disk. Without this, anyone with an etcd backup has your Secrets in the clear. - Tight RBAC.
get/liston Secrets is effectively "read all these credentials." Lock it down hard - most workloads and most humans should not be able to list Secrets cluster-wide. This is the control that matters most day to day. - An external secrets manager. For anything serious, keep the real secret in a purpose-built store - HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault - and sync it in with the External Secrets Operator (or the Secrets Store CSI driver). The source of truth lives outside the cluster with real encryption, rotation, and audit logging; Kubernetes just gets a short-lived synced copy. This is the pattern to reach for in production.
Consuming Secrets, and why volumes beat env vars
Secrets inject exactly like ConfigMaps - env vars via secretKeyRef/envFrom, or mounted files via a volume - but for Secrets the choice leans harder toward volumes.
spec:
containers:
- name: web
image: myapp:1.5.0
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: web-secrets
key: db_password
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: web-secrets
Prefer the volume mount for secrets, for concrete reasons. Environment variables leak in ways files do not:
- They show up in crash dumps and error reporters that helpfully attach the full environment.
- They are inherited by every child process you spawn, so a shell-out or subprocess carries your database password with it.
- They are readable from
/proc/<pid>/environby anything that can see the process. - Careless logging or a
printenvin a debug endpoint dumps them straight to your logs.
A mounted secret file is scoped to the mount path, readable only by processes that open that path, not inherited by children, and not swept up by environment-dumping crash handlers. It also updates in place if the Secret changes (same caveats as ConfigMaps - the app must re-read). Secret volumes are backed by tmpfs (in-memory), so they never touch the node's disk. None of this makes env vars unusable, but when the value is a credential, the file mount removes several easy ways to leak it.
What goes where, and the anti-patterns
The decision tree is short:
- ConfigMap - non-sensitive config that varies by environment: log levels, feature flags, service URLs, tuning parameters, whole config files.
- Secret - sensitive values that still need to live in the cluster: passwords, API keys, TLS certs, tokens. Accept that this is "separated and RBAC-controlled," not "encrypted," and turn on etcd encryption at rest.
- External secrets manager - anything that needs rotation, audit, or must not exist in the cluster as plaintext-equivalent: production database credentials, cloud provider keys, signing keys. Sync in via External Secrets Operator; keep the source of truth outside.
The anti-patterns to avoid:
- Secrets in plain manifests committed to git. A
SecretYAML with base64datain your repo is a plaintext credential in your repo - base64 is not obfuscation. If it is in git, it is compromised and needs rotating. Use Sealed Secrets, SOPS-encrypted manifests, or an external manager so the repo never holds a readable secret. - One giant ConfigMap for everything. A single 500-line ConfigMap shared by ten services means any change forces a rollout of all ten and couples unrelated config together. Split config along ownership and blast-radius lines - per-service, per-concern - so a change touches only what it should.
- Relying on live env-var reload. As covered above, it does not exist. If you
edita ConfigMap and expect running env vars to change, you have a stale-config incident waiting to happen. Make config changes a rollout. - Sprawling
envFromon Secrets. Dumping an entire Secret into the environment withenvFromspreads every credential across the process environment and all its children. Pull only the specific keys you need, and prefer volume mounts for the sensitive ones.
The shape of it
Configuration in Kubernetes is one principle with a few sharp edges. The principle: the image is immutable, and config is injected at runtime so the same artifact runs everywhere - ConfigMaps for the non-sensitive part, Secrets for the sensitive part. The edges: env vars are snapshotted at start and never reload live, so a ConfigMap change means nothing until you kubectl rollout restart or drive a rolling update with a config hash; mounted files update eventually but only matter if the app re-reads them. A Secret is base64, not encryption - what protects it is etcd encryption at rest, tight RBAC, and, for anything real, an external secrets manager synced in rather than checked into git. Make immutable, hashed-name config the norm, prefer volume mounts for credentials, and treat every config change as a deliberate rollout. Get that right and your config stops being a source of silent surprises. For the objects these plug into - Deployments, pods, and the control loop that ties them together - see Kubernetes Fundamentals.