ConfigMap and Secret: Pulling Configuration and Secrets Out of the Image

· tech

#kubernetes#concept

📑 Contents

Over the previous posts you’ve learned to deploy an app and expose it as a service. But a real app is still missing one piece: configuration — database addresses, feature flags, plus passwords, API keys, certificates. There’s an iron rule for these: they must not be hard-coded into the image or the code. K8s externalises configuration with ConfigMaps (non-secret settings) and Secrets (secrets), injecting them into the Pod only at runtime. This post covers why, how the two differ, and how injection works.

Why: one image has to run in every environment

The core reason to separate config from image is one sentence: the image must be “immutable and reusable across environments”. The same my-app:1.0 should run untouched in Development, Staging and Production — the only difference being the configuration injected:

One image, every environment imagemy-app:1.0(immutable) DevelopmentConfigMap + Secret StagingConfigMap + Secret ProductionConfigMap + Secret Pod (dev) Pod (staging) Pod (production) 12-factor: config in the environment, never baked in — only then can one image be reused
The same immutable image (blue), paired with each environment's own ConfigMap + Secret (orange), yields Pods for all three environments. This is the core 12-factor principle — config lives in the environment, not baked into the image. Only by pulling config out can you "build once, run anywhere", instead of building a separate image with hard-wired settings per environment; it's the same obsession as hermetic builds: an artefact that's reproducible and portable across environments

ConfigMap vs Secret: the difference is “secret or not”

The two are used almost identically; both store key-value pairs (or whole config files). The difference is whether what’s inside is secret:

  • ConfigMap: non-secret, ordinary settings — database hostname, log level, feature flags, a whole application.yaml.
  • Secret: secrets — database passwords, API keys, TLS certificates, tokens.

But here’s the most misunderstood, and most dangerous, trap: a Secret is only base64-encoded by default; it is not encrypted. base64 is “a different representation”, not “a lock” — anyone who can read the Secret can recover the plaintext with one command. So to make Secrets actually secure you need three more things: turn on etcd encryption at rest (so they’re really encrypted at the storage layer), restrict who can read them strictly with RBAC, and in Production connect an external secret manager (Vault, the cloud’s KMS). Treat a Secret as “configuration with access control”, not as “an encrypted safe”, and the false sense of security in the name won’t fool you.

Two ways to inject: environment variables vs mounted files

Config is ready; how does it get into the Pod? Two ways, each with its own fit:

Two ways to inject: env vars vs mounted files ① Environment variables (env) inject each key as an env var ✓ simple, intuitive, good for a few settings ✗ a change needs a pod restart to apply (env is fixed at start; no hot reload) ② Mounted as files (volume) appears as files in a directory ✓ good for whole config files / TLS certs ✓ update the ConfigMap → files refresh (no restart, but the app must re-read) ⚠ A Secret is only base64 by default — not encryption! Real security: encryption at rest + RBAC-restricted access + external KMS / Vault in Production
Environment variables are simplest, but they're fixed when the pod starts, so a config change needs a pod restart to take effect. Mounting as files (volume) suits whole config files or certificates, with a bonus: after you update the ConfigMap, the mounted files refresh automatically (though the app usually still has to re-read them). In practice: a few settings via env, whole config files or certificates via volume. Either way, don't forget the red text — the name Secret gives you a sense of safety, but by default it's only base64

In YAML: create the config, then inject it into the Pod

Turning the two diagrams into actual declarations. First create the ConfigMap (plaintext) and the Secret (note data takes base64 values, or use stringData to write plaintext and let K8s encode it):

apiVersion: v1
kind: ConfigMap
metadata: { name: web-config }
data:
  LOG_LEVEL: "info"                 # non-secret: plaintext as is
  application.yaml: |               # a whole config file works too
    server:
      timeout: 30s
---
apiVersion: v1
kind: Secret
metadata: { name: web-secret }
type: Opaque
stringData:
  DB_PASSWORD: "s3cr3t"             # stringData: write plaintext, K8s base64-encodes on save (still not encrypted)

Then, in the Deployment’s Pod template, demonstrate both injection methodsenv pulls into environment variables, volumeMounts mounts as files:

    spec:
      containers:
        - name: web
          image: myrepo/web:1.0
          env:
            - name: LOG_LEVEL       # ① env var: pull one key from the ConfigMap
              valueFrom: { configMapKeyRef: { name: web-config, key: LOG_LEVEL } }
            - name: DB_PASSWORD     # same for a secret, with secretKeyRef
              valueFrom: { secretKeyRef: { name: web-secret, key: DB_PASSWORD } }
          volumeMounts:
            - { name: cfg, mountPath: /etc/web }   # ② mounted as files: the whole application.yaml appears in this directory
      volumes:
        - name: cfg
          configMap: { name: web-config }

The two contrasts are the point of the second diagram: env/...KeyRef is environment-variable injection (fixed at start; a change needs a Pod restart); volumeMounts + volumes.configMap is mount as files (files refresh after the ConfigMap is updated, but the app has to re-read them). To pour a whole ConfigMap/Secret into environment variables in one go, there’s also envFrom, which saves a lot of lines.

Reflections

Separating config from image is the foundation of “build once, run anywhere”

When I was learning Docker/K8s I did the stupid thing of writing the DB address, even credentials, straight into the image — the result was rebuilding an image every time I changed environments, one for dev, one for prod, a total mess, and I nearly pushed a password to git. What ConfigMap/Secret taught me is a clean boundary: the image owns “code and dependencies”; config owns “where this runs and with what parameters”; keep them apart. The value of that boundary is making “the same artefact runs in every environment” real — the image you validated in Staging goes to Production without a single bit changed, only a different config. It’s the same coin as the “reproducible, portable artefact” of hermetic builds, two faces: one guards the purity of the build artefact, the other the injection of runtime config.

A Secret is only base64 — the name gives you a false sense of safety

The name “Secret” is dangerous because it sounds so safe that people unconsciously assume “put it in a Secret and it’s locked”. But by default it’s only base64; anyone with access can recover the plaintext in a second. The lesson goes beyond K8s: never let a thing’s “name” do your security judgment for you. Now whenever I see a feature claiming “encrypted”, “secure”, “protected”, I ask one more question: “what does it actually do, and whom does it stop” — is it real encryption or just encoding? Does it stop outsiders, or also insiders with permissions? The name is marketing; the actual threat model is engineering. Knowing specifically what a security mechanism blocks and what it doesn’t matters far more than remembering what it’s called.

Good config management makes “changing a setting” not the same as “changing the program”

I increasingly think you can tell how mature a system is by “how painful it is to change one setting”. In an immature system, changing a parameter means touching code, rebuilding, redeploying — every change a big production — so people avoid changing anything and hard-code the settings. In a mature system, config is external and injected — flip a feature flag, adjust a threshold, no code touched, maybe not even a restart (if mounted as files). ConfigMap/Secret make config a first-class citizen, fully separating “adjusting behaviour” from “rewriting logic”, and that separation is itself a form of maintainability. Separating “what changes” (config) from “what mostly doesn’t” (code), so the former can be adjusted cheaply — this isn’t just K8s wisdom; it’s a thread running through every good architecture I’ve seen.