Skip to content
BytePatterns

Kubernetes ConfigMap vs Secret: Env Vars, Volumes and Base64

8 min readBytePatterns

ConfigMap vs Secret in Kubernetes: env vars vs mounted files, why env goes stale until a restart, why base64 is not encryption, and how to lock Secrets down.

The rule behind ConfigMaps and Secrets is one sentence: build one image and run it everywhere, with settings and passwords kept beside it, not in it. The interview questions are about the details. What is the real difference between a ConfigMap and a Secret? Why does changing a ConfigMap not change a running app? Is a Secret encrypted? Everything below comes from the Kubernetes documentation pages listed at the end, as of September 2026.

The problem it solves

The same api image has to run in staging and production with a different log level, different feature flags and a different database password. Baking that into the image means one build per environment, and a password in an image is readable by anyone who can pull it. You need configuration that lives in the cluster, is attached to Pods at start, and can change without a rebuild.

The intuition

A ConfigMap holds non-confidential key-value data: log levels, feature flags, URLs. Its data field holds UTF-8 strings and binaryData holds base64-encoded binary values. It cannot exceed 1 MiB, and it provides no secrecy or encryption. The Pod and the ConfigMap must be in the same namespace.

A Secret holds small sensitive values: passwords, tokens, keys. Individual Secrets are limited to 1 MiB. Values in data are base64-encoded, and stringData lets you write them as plain strings. Opaque is the default type; built-in types exist for TLS certificates, registry credentials, basic and SSH authentication, and service account tokens.

A Pod consumes either one in the same ways: as environment variables, individually or with envFrom, or as files in a read-only volume. A ConfigMap can also feed command-line arguments, or be read from the API by code in the Pod.

The choice between env and files decides what happens on a change. Environment variables are set when the container starts and are not updated afterwards; the container needs a restart. Files in a mounted volume are updated, eventually: the delay can be as long as the kubelet's sync period plus its cache propagation delay. A file mounted with subPath never receives updates.

The security model is weaker than the name suggests. Base64 is an encoding, not encryption; anyone who can read the Secret can decode it. By default, Secrets are stored unencrypted in etcd. Anyone authorized to create a Pod in a namespace can use that access to read any Secret in that namespace, including indirectly by creating a Deployment. The documentation's recommended steps are to enable encryption at rest, apply least-privilege RBAC, restrict a Secret to the containers that need it, and consider an external secret store. Granting list on Secrets effectively grants their contents. When the kubelet mounts a Secret, it keeps the data in tmpfs, not on the node's disk.

Both kinds can be marked immutable. That protects against accidental changes and reduces load on the API server, which no longer needs to watch them; the only way to change one afterwards is to delete and recreate it.

Watch it run

The animation starts from one image, api:1.4.2, with no config inside. A ConfigMap holds non-confidential pairs, LOG_LEVEL=info and FEATURE_X=on, and envFrom turns each key into an environment variable when the container starts. A Secret holds the database password, stored base64-encoded as Y2hhbmdlLW1l. Base64 is an encoding, not encryption: anyone who can read the Secret decodes it to change-me. By default Secrets sit unencrypted in etcd, and anyone allowed to create Pods in the namespace can read them, so the fix is encryption at rest and least-privilege RBAC. Mounted as a read-only volume, the password appears as a file, /etc/db/password. Then LOG_LEVEL changes to debug, and the running container still sees info: env was fixed when it started. Mounted files update eventually, but not through subPath; for env, roll the Deployment, and new Pods read the new value. ConfigMap for settings, Secret for credentials, locked down, and the same image everywhere.

ConfigMaps, Secrets & Env

Step 1 of 11

One image, every environment. The settings live beside it, in objects the Pod reads when it starts.

The same interactive animation as the lesson — step through it with the controls.

The code

The lesson's objects and Pod template:

# illustrative — kubectl create configmap api-config --from-literal=LOG_LEVEL=info
#                kubectl create secret generic api-db --from-literal=password=change-me
containers:
  - name: api
    envFrom: [{ configMapRef: { name: api-config } }]
    volumeMounts: [{ name: db, mountPath: /etc/db, readOnly: true }]
volumes:
  - { name: db, secret: { secretName: api-db } }

The value stored in the Secret, and how little it takes to read it back:

import base64

stored = base64.b64encode(b"change-me").decode()
print(stored)                                       # Y2hhbmdlLW1l
print(base64.b64decode(stored).decode())            # change-me

A toy model of what a running container sees after a ConfigMap changes, not the kubelet: env copied at start, volume files refreshed by a sync, a subPath file never refreshed, and an immutable ConfigMap that refuses updates:

class ToyConfigMap:
    """Toy ConfigMap: key-value data, optionally immutable."""
    def __init__(self, data, immutable=False):
        self.data, self.immutable = dict(data), immutable

    def update(self, key, value):
        if self.immutable:
            raise ValueError("immutable: delete and recreate instead")
        self.data[key] = value

class ToyPod:
    """Toy model of what a container sees, as documented in September 2026."""
    def __init__(self, cm):
        self.cm = cm
        self.env = dict(cm.data)                    # envFrom: fixed at container start
        self.volume = dict(cm.data)                 # volume files: refreshed by the kubelet
        self.subpath = cm.data["LOG_LEVEL"]         # subPath mount: never refreshed

    def kubelet_sync(self):                         # "eventually": sync period + cache delay
        self.volume = dict(self.cm.data)

    def sees(self):
        return self.env["LOG_LEVEL"], self.volume["LOG_LEVEL"], self.subpath

cm = ToyConfigMap({"LOG_LEVEL": "info", "FEATURE_X": "on"})
pod = ToyPod(cm)
cm.update("LOG_LEVEL", "debug")
print(pod.sees())                                   # ('info', 'info', 'info')
pod.kubelet_sync()
print(pod.sees())                                   # ('info', 'debug', 'info')
pod = ToyPod(cm)                                    # rollout restart: a new Pod
print(pod.sees())                                   # ('debug', 'debug', 'debug')

frozen = ToyConfigMap({"LOG_LEVEL": "info"}, immutable=True)
try:
    frozen.update("LOG_LEVEL", "debug")
except ValueError as e:
    print(e)                                        # immutable: delete and recreate instead

The model against a direct statement of the documented rules, over 2,000 random sequences of updates, syncs and restarts: env and subPath show the value at the last start, volume files the value at the last sync or start:

import random

random.seed(18)
ok = True
for _ in range(2000):
    cm = ToyConfigMap({"LOG_LEVEL": "v0"})
    pod = ToyPod(cm)
    at_start = at_sync = "v0"
    for step in range(random.randint(1, 12)):
        event = random.choice(["update", "sync", "restart"])
        if event == "update":
            cm.update("LOG_LEVEL", f"v{step + 1}")
        elif event == "sync":
            pod.kubelet_sync()
            at_sync = cm.data["LOG_LEVEL"]
        else:
            pod = ToyPod(cm)
            at_start = at_sync = cm.data["LOG_LEVEL"]
    ok &= pod.sees() == (at_start, at_sync, at_start)
print(ok)                                           # True

The complexity

The costs are staleness and exposure, not time:

  • Env is stale until a restart. A config change means a rollout, which is also a clean way to apply it everywhere at once.
  • Mounted files are eventually fresh, so for a while different Pods can see different values, and the app has to notice the file changed.

Where it goes wrong

  • Passwords in a ConfigMap or in the Dockerfile. Neither provides secrecy.
  • Treating base64 as protection. Committing a Secret manifest to a repository publishes the password.
  • Expecting env to change after editing the ConfigMap. Roll the Deployment.
  • Mounting with subPath and expecting updates. It never receives them.
  • Broad RBAC. list on Secrets, or the right to create Pods in a namespace, is enough to read them.

When it shows up in interviews

It comes up in DevOps and platform interviews as "ConfigMap or Secret?", "how do you rotate a password?" and "is a Secret encrypted?". Expect follow-ups on env versus volume, why a change did not take effect, and how to restrict access. Knowing that the default is not secure is most of the answer.

How to say it in an interview

"Both keep configuration out of the image. A ConfigMap is for non-confidential settings; a Secret is for credentials. A Pod reads either as environment variables or as mounted files. Env is fixed when the container starts, so a change needs a rollout; mounted files update eventually, except through subPath. A Secret is only base64-encoded and, by default, unencrypted in etcd, and anyone who can create Pods in the namespace can read it, so I would enable encryption at rest, use least-privilege RBAC, mount it only into the containers that need it, and consider an external secret store."

Rolling the Deployment after a change is covered in Kubernetes rolling updates, and the image that stays the same everywhere in Docker image layers.

Sources