ConfigMaps and Secrets: Configuration as a First-Class Object🔗
Part of Essentials: Core Primitives
This article is part of Essentials: Core Primitives. It assumes you're comfortable with Pods and Services.
A container image should be environment-agnostic. The same myapp:v1.4.2 artifact that runs in dev should run in staging and production unchanged — same bytes, same SHA. The only thing that differs is configuration: the database URL, the log level, the feature flags, the API credentials.
Bake any of that into the image and you've broken the contract. Now "promote to prod" means "rebuild," every environment is a different artifact, and a credential is sitting in an image layer that anyone with pull access can docker history out of.
ConfigMaps and Secrets are how Kubernetes keeps configuration out of the image — as cluster objects you mount or inject at runtime. ConfigMaps for non-sensitive data, Secrets for sensitive data. Understanding the difference between them, and the real (and limited) security boundary a Secret provides, is the point of this article.
What You'll Learn
By the end of this article, you'll understand:
- Why configuration is externalized from the image, and what that buys you operationally
- ConfigMaps — defining them declaratively, and the imperative shortcut
- Two ways to consume config — environment variables vs mounted files — and the tradeoffs
- Secrets — what they actually are (and the hard truth that base64 is not encryption)
- Update propagation — why an env-var change needs a restart but a mounted file doesn't
- The production security model — controlling who can read secrets, encryption at rest, and sourcing secrets from a real secret manager
graph TD
CM["ConfigMap<br/>non-sensitive key/values"]
SEC["Secret<br/>sensitive key/values"]
Pod["Pod"]
EnvC["env vars"]
EnvS["env vars"]
VolC["mounted files<br/>/etc/config"]
VolS["mounted files<br/>/etc/secrets"]
CM --> EnvC --> Pod
CM --> VolC --> Pod
SEC --> EnvS --> Pod
SEC --> VolS --> Pod
style CM fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
style SEC fill:#c53030,stroke:#cbd5e0,stroke-width:2px,color:#fff
style Pod fill:#2f855a,stroke:#cbd5e0,stroke-width:2px,color:#fff
style EnvC fill:#4a5568,stroke:#cbd5e0,stroke-width:2px,color:#fff
style EnvS fill:#4a5568,stroke:#cbd5e0,stroke-width:2px,color:#fff
style VolC fill:#4a5568,stroke:#cbd5e0,stroke-width:2px,color:#fff
style VolS fill:#4a5568,stroke:#cbd5e0,stroke-width:2px,color:#fff
Why Externalize Configuration?🔗
The principle predates Kubernetes — it's config factor III of the Twelve-Factor App: keep config in the environment, not the code. Kubernetes just gives you typed objects to do it with.
The payoff is concrete:
| Benefit | What it gives you |
|---|---|
| One artifact, every environment | Promote the exact image you tested — differences live in the ConfigMap/Secret, not a rebuild. |
| Config changes without a code redeploy | Update the object and restart the workload — no CI pipeline run. |
| Separation of duties | The config repo and the secret manager can be owned by different people than the application code. On a GitOps-managed cluster this matters a lot — app config is one concern, credentials are another. |
| Auditability | Configuration is a versioned object, not a value buried in an image layer. |
ConfigMaps and Secrets are nearly identical mechanically. The split exists so that the sensitive values can be treated differently — restricted by access controls (who's allowed to read them), encrypted at rest, kept out of Git, sourced from a real secret manager. Use the right one for the data; don't put a password in a ConfigMap because it was convenient.
ConfigMaps: Non-Sensitive Configuration🔗
A ConfigMap is a map of string key/values that lives in your namespace. Think of it as the cluster-native replacement for a .env file or a config.yaml you'd otherwise bake into the image.
Define It Declaratively (the default)🔗
Config that isn't sensitive belongs in version control next to your other manifests, so the YAML form is the one you'll actually live with:
| app-config.yaml | |
|---|---|
- Each value is stored as a string — note
"true"and"8080"are quoted; ConfigMap values are always strings, never typed. - Feature flags are the textbook ConfigMap use case — flip behaviour without a rebuild.
- A value can be a whole file. The
|block scalar preserves newlines, soapp.propertiesmounts as a real multi-line file.
kubectl apply -f app-config.yaml
# configmap/app-config created
The imperative shortcut — for scratch work only
kubectl create configmap app-config --from-literal=LOG_LEVEL=info --from-file=app.properties builds a ConfigMap without writing YAML. It's handy at a prompt, but it produces an object with no manifest in Git — invisible to the next person and impossible to diff. Use it to experiment; commit a manifest for anything that outlives the afternoon.
Consuming Config in a Pod🔗
There are two ways to get a ConfigMap's data into a container, and the choice has real consequences — especially for updates (covered below).
| pod-config-env.yaml | |
|---|---|
- Map one specific key to one env var — use this when the var name differs from the key.
- Inject every key in the ConfigMap as an env var at once. Convenient, but it pulls in keys like
app.propertiestoo (which becomes an awkward multi-line env var).
Tradeoff: simplest for apps that already read config from the environment. But env vars are read once at process start — a ConfigMap change does nothing until the Pod restarts.
| pod-config-volume.yaml | |
|---|---|
- Each key becomes a file under
/etc/config/—/etc/config/LOG_LEVEL,/etc/config/app.properties, etc. - The whole ConfigMap is projected as a read-only volume.
Tradeoff: the kubelet updates the files in place when the ConfigMap changes (eventually — see below). Apps that re-read their config file, or watch it for changes, pick up new values with no restart.
Secrets: Same Mechanics, Different Trust🔗
A Secret looks almost exactly like a ConfigMap and is consumed the same two ways (env vars or mounted files). What's different is how the cluster treats it: Secrets can be restricted with their own access-control rules (who may read them), encrypted at rest, and are kept out of normal get -o yaml habits. They're also the only sane place for passwords, tokens, and TLS keys.
base64 is encoding, not encryption
This is the single most misunderstood thing about Secrets. The values are base64-encoded, which is trivially reversible — base64 -d and it's plaintext. A Secret is not "encrypted" just because it's a Secret. Anyone who can read the Secret object (via the API or etcd) can read its contents. The real protections are external to the object: access controls limiting who can read it, and encryption-at-rest in the cluster's datastore. Internalize this now and you won't make the career-limiting mistake of treating a base64 string as safe.
Creating Secrets🔗
Secrets invert the declarative-first rule for one reason: you must not commit plaintext or base64 Secret values to Git. Base64 isn't encryption, so a committed Secret manifest is a leaked credential the moment the repo is cloned.
kubectl create secret generic db-credentials \
--from-literal=username=appuser \
--from-literal=password='s3cr3t-do-not-commit'
Nothing sensitive lands on disk or in Git. This is the right move for a dev cluster.
| secret.yaml — DO NOT commit with real values | |
|---|---|
Opaqueis the generic type — arbitrary key/values. Specialized types exist (see the table below).stringDatatakes plaintext and Kubernetes base64-encodes it for you — far less error-prone than hand-encoding into adata:block. Useful for templating tools, but the rendered file still holds the secret, so it must never be committed.
In production you don't manage Secret YAML by hand at all. A controller like the External Secrets Operator pulls values from a real secret manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager) and materializes the Kubernetes Secret for you. The source of truth is the vault; Git holds only a non-sensitive ExternalSecret reference. This is the pattern to reach for once you're past a dev cluster — it's covered properly in the Mastery tier.
Consuming Secrets — prefer files over env vars🔗
| pod-secret-volume.yaml | |
|---|---|
- Secret mounts should always be read-only.
0400= readable only by the container's user. Lock the file down.
Why mounted files beat env vars for secrets
Environment variables leak. kubectl exec <pod> -- env dumps them in one command, child processes inherit them, and crash/observability tooling routinely captures the full environment into logs. A mounted Secret file isn't sprayed across the process tree and can be permission-restricted. When you have the choice, mount secrets as files — reserve secret env vars for apps that genuinely can't read from a path.
Secret Types🔗
Opaque covers most cases, but Kubernetes defines typed Secrets that other components know how to consume:
| Type | Use Case | Key Fields |
|---|---|---|
Opaque |
Generic credentials, tokens | arbitrary keys |
kubernetes.io/tls |
TLS cert + key (e.g. for Ingress) | tls.crt, tls.key |
kubernetes.io/dockerconfigjson |
Pull from a private registry | .dockerconfigjson |
kubernetes.io/basic-auth |
Basic-auth credentials | username, password |
kubernetes.io/ssh-auth |
SSH private key | ssh-privatekey |
kubernetes.io/service-account-token |
Service-account token | managed by the API server |
The registry-pull case comes up constantly — a Pod referencing a private image needs an imagePullSecrets entry pointing at a dockerconfigjson Secret, or it sits in ImagePullBackOff:
| private-image-pull.yaml | |
|---|---|
- Create this Secret with
kubectl create secret docker-registry my-registry --docker-server=... --docker-username=... --docker-password=....
Update Propagation: the Gotcha That Bites Everyone🔗
Change a ConfigMap or Secret and the behaviour depends entirely on how it's consumed — this is the detail that turns into a 2 a.m. "but I updated the config" incident:
| Consumed as | What happens on update |
|---|---|
| Environment variable | Nothing. Env vars are set at process start. The running Pod keeps the old value until it's recreated. |
| Mounted file | The kubelet refreshes the file automatically, typically within a minute (it's eventually consistent, not instant). The app sees the new value if it re-reads the file. |
Because env-var config can't be hot-reloaded, the standard way to roll out a config change to running Pods is to trigger a fresh rollout — which the workload controller does declaratively:
This recreates the Pods (respecting the Deployment's rollout strategy, so it's zero-downtime), and the new Pods read the current ConfigMap/Secret. It does not mutate any resource by hand — it just asks the controller to reconcile a new generation.
Make config changes trigger rollouts automatically
A common production pattern is to hash the ConfigMap/Secret contents into a Pod-template annotation (Helm and Kustomize both have built-ins for this). When the config changes, the hash changes, the Pod template changes, and the Deployment rolls automatically — no one has to remember to run rollout restart. Worth knowing exists; you'll meet it again in Efficiency/Mastery.
Common Pitfalls🔗
ConfigMap or Secret not found — Pod stuck in CreateContainerConfigError
The Pod references a ConfigMap/Secret that doesn't exist in its namespace. These objects are namespace-scoped (see Namespaces) and there is no cross-namespace reference — a Pod in dev cannot mount a Secret from staging.
Updated the ConfigMap, app still uses the old value
You're consuming it as an environment variable. Env vars don't hot-reload. Either switch to a mounted file (if the app re-reads it) or kubectl rollout restart the workload.
Treated a Secret as if it were encrypted
It isn't. If a base64 value ended up somewhere it shouldn't — a committed manifest, a Slack paste, a log line — treat it as compromised and rotate it. Decoding it back is one command.
Practice Exercises🔗
Exercise 1: One config, two consumption styles
Create a ConfigMap app-config with LOG_LEVEL=debug and app.properties containing server.port=8080. Mount it into a Pod as files, then verify the app would see them.
Solution
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: "debug"
app.properties: |
server.port=8080
apiVersion: v1
kind: Pod
metadata:
name: cfg-demo
spec:
containers:
- name: app
image: busybox:1.35
command: ['sh', '-c', 'cat /etc/config/LOG_LEVEL; cat /etc/config/app.properties; sleep 3600']
volumeMounts:
- name: config
mountPath: /etc/config
readOnly: true
volumes:
- name: config
configMap:
name: app-config
kubectl apply -f app-config.yaml
kubectl apply -f pod.yaml
kubectl logs cfg-demo
# debug
# server.port=8080
Why files here: mounting as a volume means a later kubectl apply of an edited ConfigMap propagates to the file automatically — no Pod restart needed if the app re-reads it.
Exercise 2: Prove that base64 isn't security
Create a Secret with kubectl create secret generic demo --from-literal=token=hunter2, then recover the plaintext using only kubectl.
Solution
kubectl create secret generic demo --from-literal=token=hunter2
kubectl get secret demo -o jsonpath='{.data.token}' | base64 -d
# hunter2
Anyone with get secret permission in the namespace can do this. The protection isn't the encoding — it's the access-control rule that decides who can run that get. That's the whole point of keeping secrets in Secret objects (restrictable, encryptable at rest) rather than ConfigMaps.
Quick Recap🔗
| Concept | What to Know |
|---|---|
| ConfigMap | Non-sensitive string key/values; manage declaratively in Git |
| Secret | Sensitive data; base64-encoded, not encrypted; keep out of Git |
| env vars | Simple; set once at startup; no hot-reload; leak easily |
| Mounted files | Auto-refresh (eventually); permission-controllable; preferred for secrets |
| Update propagation | env = restart required; file = automatic within ~1 min |
rollout restart |
Declarative way to push config to running Pods |
| Real secret security | Access controls + encryption at rest + a real secret manager — not the object itself |
Further Reading🔗
Official Documentation🔗
- ConfigMaps - Concept and API reference
- Secrets - Including built-in types
- Secrets Good Practices - The official hardening guidance
- Encrypting Secret Data at Rest - Making etcd storage actually encrypted
Deep Dives🔗
- The Twelve-Factor App: Config - The principle this all implements
- External Secrets Operator - Sourcing Secrets from Vault / cloud secret managers
Related Articles🔗
- Pods: What Actually Runs Your Application - Where these objects get mounted
- Namespaces - ConfigMaps and Secrets are namespace-scoped
- Labels and Selectors - Organizing and querying these objects
What's Next?🔗
You can keep configuration out of your images and reason honestly about what a Secret does and doesn't protect. Next: the boundary that scopes all of these objects — and the multi-tenancy controls built on it.
Next: Namespaces — isolation boundaries, resource quotas, and the soft-vs-hard reality of separating teams on one cluster.