Contents
6 Chapter

ConfigMap and Secret

Separate config and passwords from the manifest with ConfigMap and Secret. This is how Kubernetes solves 12-factor's "store config in the environment" principle, the three injection methods env · envFrom · volume, the fact that a Secret's base64 is not encryption, and why a Pod restart is needed when config changes.

In the manifests we’ve built through Chapter 5 Service, one thing sits awkwardly — values like the image tag · port · domain are written directly in the manifest. In this chapter we cover the two objects that separate those values from the manifest body, ConfigMap and Secret. We organize how to inject config, how secret values differ, and how to re-reflect a value into a Pod when it changes.

By the end of this chapter you’ll have the shape where you can apply the same Deployment manifest to dev / staging / prod anywhere. The values that differ per environment only need to be split off on the ConfigMap · Secret side, and one set of the workload definition is enough.

A line from 12-factor — store config in the environment #

The problem ConfigMap · Secret try to solve isn’t something K8s invented. It’s a pattern that became settled doctrine for web-app operation long before containers were common. The most-cited source is item III of the 12-factor app, expressed in one line as this.

Store config in the environment.

Here “config” refers to the values that differ per environment — DB host, external API keys, log level, passwords, and the like. Before the container era, this was solved as environment variables, config files under /etc/, .env files, and so on. In the K8s era, ConfigMap (plain config values) and Secret (secret values) take on that role.

Pinning in one line each why you’d bother to separate them gives these three.

  • Change config without changing the image · code — you can change behavior by giving the same container image different environment variables only. No new build, no new image tag needed.
  • Multi-deploy per environment — the dev / staging / prod manifests are nearly identical, with the differing parts split off into ConfigMap · Secret. You don’t have to clone the workload definition wholesale per environment.
  • Don’t put secret values in git — if values like a DB password or API token are written in plaintext in the manifest body, that immediately goes up into the git repository. Separating them into a Secret object lets the manifest write just the one line “reference that Secret,” with the actual values entering the cluster by a separate path.

These three drive nearly every operational decision. So let’s look at ConfigMap first.

ConfigMap — a key-value bundle of config #

A ConfigMap, as the name says, is a K8s object holding a key-value collection of config values. You make it with one manifest.

web-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: web-config
data:
  LOG_LEVEL: "info"
  APP_GREETING: "hello from k8s"
  app.conf: |
    server {
      listen 80;
      location / {
        return 200 "ok\n";
      }
    }

apiVersion, like Service, is the core group’s v1. ConfigMap isn’t a workload controller, so it isn’t apps/v1.

The key field is one place — data. The shape inside it splits two ways.

  • Short key-value (scalar) — one-line values like LOG_LEVEL: "info", APP_GREETING: "hello from k8s". A good shape to pour straight into environment variables.
  • Multiline file — long text written with YAML’s block scalar (|). You can put things like an nginx config file, an app’s config.yaml, or a small SQL script in directly, like app.conf in the example above.

Worth pinning the imperative creation too #

Besides the manifest path, there’s the imperative path to make it.

create a ConfigMap imperatively
kubectl create configmap web-config \
  --from-literal=LOG_LEVEL=info \
  --from-literal=APP_GREETING="hello from k8s" \
  --from-file=app.conf

--from-literal registers an inline key-value, and --from-file brings in a file from disk directly and registers it as a key. It’s fast, but there’s a clear downside — the ConfigMap created doesn’t remain as a manifest. Even if the next person looks at the cluster’s ConfigMap, there’s no way to trace from the git repository where this value came from. In operations you almost always go the kubectl apply -f route, and use imperative only briefly during debugging · experimenting.

Size limit — 1 MiB #

The total of values a ConfigMap holds cannot exceed 1 MiB (mebibyte). This isn’t an arbitrary K8s decision but the size ceiling per object that the underlying etcd can hold. A small config file · environment variable bundle fits well within this limit, but cramming a large static asset (e.g., model weights, a big SQL schema, a browser bundle file) into a ConfigMap doesn’t fit the pattern. The proper way for such an asset is to put it in separate storage (S3 · GCS, PV) and have the container fetch it.

Let’s make it and take a look.

apply the ConfigMap
kubectl apply -f web-config.yaml
example output
configmap/web-config created
list ConfigMaps
kubectl get cm
example output
NAME               DATA   AGE
kube-root-ca.crt   1      2d
web-config         3      10s

Pinning the column names in one line — NAME / DATA / AGE. The number in the DATA column is the count of keys under data. In the example above we put 3 keys (LOG_LEVEL, APP_GREETING, app.conf), so it shows 3. kube-root-ca.crt is a ConfigMap the cluster holds for itself, so you don’t need to worry about it.

Three ways to inject a ConfigMap into a Pod #

Just making a ConfigMap doesn’t let a Pod notice its values. You have to write in the manifest how a Pod receives those values. There are three methods, and grasping the difference among them is the most practical part of this chapter.

1. Single key → environment variable (env.valueFrom.configMapKeyRef) #

The most explicit shape, mapping one key of a ConfigMap to one environment variable of the container.

env.valueFrom.configMapKeyRef
spec:
  containers:
    - name: web
      image: nginx:1.27
      env:
        - name: LOG_LEVEL
          valueFrom:
            configMapKeyRef:
              name: web-config
              key: LOG_LEVEL

env is the field we saw briefly in the manifest of Chapter 3 kubectl and your first Pod. You usually write it inline as value: "info", but using valueFrom means pull the value from another object. configMapKeyRef’s name is the ConfigMap name, and key is the name of the key inside it.

The advantage is explicitness — which environment variable came from which key of which ConfigMap is shown right in the manifest. The downside is length. With several environment variables, that many lines pile up.

2. All keys → environment variables at once (envFrom.configMapRef) #

The shape used when you want to pour all the keys inside a ConfigMap into environment variables at once.

envFrom.configMapRef
spec:
  containers:
    - name: web
      image: nginx:1.27
      envFrom:
        - configMapRef:
            name: web-config

Written this way, all keys of web-config are automatically injected as the container’s environment variables. LOG_LEVEL, APP_GREETING, app.conf all become environment variables with the same name. It’s short and easy, but one thing to watch — a ConfigMap’s key names become the environment variable names unchanged. So if you intend to use a ConfigMap with envFrom, keeping key names in UPPER_SNAKE_CASE is fine. A key with a dot, like app.conf, isn’t suitable as an environment variable (environment variables with a dot are awkward to handle in a shell). Such a key should go to the volume mount in the next section.

3. Mount as a file (volumes.configMap + volumeMounts) #

A value that only makes sense as a file, like app.conf, must be placed into the container as a file. Mounting a ConfigMap like a volume creates one file per key.

volumes + volumeMounts
spec:
  containers:
    - name: web
      image: nginx:1.27
      volumeMounts:
        - name: app-conf
          mountPath: /etc/myapp
  volumes:
    - name: app-conf
      configMap:
        name: web-config
        items:
          - key: app.conf
            path: app.conf

How to read it is two steps.

  • volumes — a volume defined at the Pod level. Above we made a volume named app-conf, whose content is set to the app.conf key of the web-config ConfigMap. Writing items lets you mount only some keys selected from inside the ConfigMap. Omit items and every key of the ConfigMap is mounted as a separate file.
  • volumeMounts — at the container level, decides at which path of the container filesystem to insert the volume above. With mountPath: /etc/myapp, the content is visible inside the container at the path /etc/myapp/app.conf.

When to use which #

Organizing the use of the three branches into one table makes the decision much faster.

Injection shapeSuitable whenDescription
env.valueFrom.configMapKeyRefone or two environment variablesexplicit but lengthy
envFrom.configMapRefa whole bundle of environment variablesshort but needs a key-naming convention
volumes.configMapa whole config file as a filesuitable when the app is built to read from a file

A simple decision rule in your head — one or two values, env; a whole key-value bundle that should become environment variables, envFrom; only meaningful as a file, volume.

Putting it all together — Deployment + ConfigMap #

Let’s gather the three shapes above into one manifest. We bring in the web Deployment from Chapter 4 and set it up so one environment variable comes via env, the rest via envFrom, and app.conf is mounted as a volume.

web.yaml — a Deployment pulling in a ConfigMap
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: nginx:1.27
          ports:
            - containerPort: 80
          env:
            - name: LOG_LEVEL
              valueFrom:
                configMapKeyRef:
                  name: web-config
                  key: LOG_LEVEL
          envFrom:
            - configMapRef:
                name: web-config
          volumeMounts:
            - name: app-conf
              mountPath: /etc/myapp
      volumes:
        - name: app-conf
          configMap:
            name: web-config
            items:
              - key: app.conf
                path: app.conf

(For the example we wrote both env and envFrom — in practice you usually use only one. If you pull the same key from the same ConfigMap twice, the one defined last takes precedence.)

Apply it and check the result.

apply
kubectl apply -f web-config.yaml -f web.yaml
example output
configmap/web-config unchanged
deployment.apps/web created

Go inside the Pod and see whether the environment variables and the file actually came in.

check environment variables
kubectl exec -it deploy/web -- env | grep -E "LOG_LEVEL|APP_GREETING"
example output
LOG_LEVEL=info
APP_GREETING=hello from k8s
check the file mount
kubectl exec -it deploy/web -- cat /etc/myapp/app.conf
example output
server {
  listen 80;
  location / {
    return 200 "ok\n";
  }
}

It’s confirmed that exactly what we wrote in the ConfigMap shows up inside the container as environment variables and a file. That closes the loop. The manifest body doesn’t have the values themselves written in it, only the reference “pull from the ConfigMap.”

Secret — separating secret values #

If a ConfigMap is an object for plain config values, a Secret is an object for values that shouldn’t be left in plaintext in the manifest body, like passwords · tokens · certificates. The manifest shape is nearly the same as a ConfigMap.

db-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-secret
type: Opaque
stringData:
  DB_USER: "myapp"
  DB_PASSWORD: "s3cret-do-not-commit"

apiVersion is identically v1. There are two parts that differ from a ConfigMap.

  • type — a Secret comes in several kinds, so it has a type field. For a plain key-value bundle it’s Opaque (the default).
  • stringData vs data — two paths to write values in the Secret body.

data vs stringData — and the one line “base64” #

The most important one line of this chapter is here.

Despite the name Secret, the default behavior is merely base64 encoding. It is not encryption.

Looking with kubectl get secret db-secret -o yaml, the manifest’s stringData is gone and only base64-encoded strings show under data.

apply and check
kubectl apply -f db-secret.yaml
kubectl get secret db-secret -o yaml
example output — excerpt
apiVersion: v1
kind: Secret
metadata:
  name: db-secret
type: Opaque
data:
  DB_USER: bXlhcHA=
  DB_PASSWORD: czNjcmV0LWRvLW5vdC1jb21taXQ=

bXlhcHA= and czNjcmV0LWRvLW5vdC1jb21taXQ= look hard, but base64 isn’t a security device — it’s encoding to move binary into text. You can unwrap it in one line.

base64 decode
kubectl get secret db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
example output
s3cret-do-not-commit

The original value comes out unchanged. So handling a Secret object should be regarded as essentially the same as handling a plaintext secret value. Real protection has to come from another layer — we’ll pin it briefly later.

The difference between data and stringData is only whether it’s easy for a person to write.

  • data — you write values pre-encoded with base64. It’s cumbersome to write by hand.
  • stringData — write plaintext and K8s takes it and base64-encodes it on its own. When a person makes a Secret with a manifest, you almost always use this side.

Imperative creation is also in the same direction as ConfigMap.

create a Secret imperatively
kubectl create secret generic db-secret \
  --from-literal=DB_USER=myapp \
  --from-literal=DB_PASSWORD=s3cret-do-not-commit

Secret types, one line each #

A Secret has a few set types by purpose. Let’s pin the four commonly met one line at a time.

typeUse
Opaquedefault. an arbitrary key-value bundle
kubernetes.io/dockerconfigjsonprivate container registry credentials. referenced from imagePullSecrets
kubernetes.io/tlsa TLS certificate · key pair. referenced from an Ingress’s HTTPS endpoint
kubernetes.io/service-account-tokena ServiceAccount token. appears together with RBAC

Of these, what a person touches directly via manifest is usually about Opaque and kubernetes.io/tls. dockerconfigjson is commonly made with the dedicated command kubectl create secret docker-registry, and service-account-token is mostly handled by K8s on its own. The relationship between RBAC and ServiceAccount is covered in Chapter 14 RBAC / NetworkPolicy / ResourceQuota and Chapter 16 RBAC / ServiceAccount in depth.

get secret output columns #

list Secrets
kubectl get secret
example output
NAME        TYPE     DATA   AGE
db-secret   Opaque   2      1m

The only difference from a ConfigMap is the added TYPE column — NAME / TYPE / DATA / AGE. The number in DATA is the key count.

Injecting a Secret into a Pod #

The shape of injecting a Secret into a Pod is identical to a ConfigMap — only the key names differ slightly. Let’s organize the three at once.

1. Single key → environment variable (env.valueFrom.secretKeyRef) #

env.valueFrom.secretKeyRef
env:
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: db-secret
        key: DB_PASSWORD

All that changes is the ConfigMap’s configMapKeyRef becoming secretKeyRef.

2. All keys → environment variables at once (envFrom.secretRef) #

envFrom.secretRef
envFrom:
  - secretRef:
      name: db-secret

configMapRef becomes secretRef. With the same mindset, keeping key names in UPPER_SNAKE_CASE is fine.

3. Mount as a file (volume.secret) #

volumes + volumeMounts (Secret)
volumes:
  - name: db-creds
    secret:
      secretName: db-secret
volumeMounts:
  - name: db-creds
    mountPath: /etc/db
    readOnly: true

A ConfigMap volume writes name under the configMap key, but a Secret volume’s name is slightly different, secretName under the secret key. Another difference is the disk location — a Secret file unrolled by a volume mount isn’t dropped in plaintext on the node’s disk. K8s puts it on tmpfs (a memory-based filesystem) so it disappears when the node reboots. A ConfigMap has no such protection.

To handle real secret values safely #

Having pinned the one line that a Secret is merely base64, let’s also briefly organize how secret values are handled in real operations. Deep installation is outside this chapter’s scope, and the goal is just to know the names.

  • Encryption at the etcd layer — setting the apiserver’s EncryptionConfiguration and integrating with KMS (AWS KMS, GCP KMS, etc.) stores Secret values entering etcd encrypted. It’s the first item you turn on when operating a cluster yourself.
  • Sealed Secrets (Bitnami) — converts a Secret manifest into an object called a SealedSecret, encrypted with the cluster’s public key. This encrypted manifest is safe to put in git, and once it enters the cluster a controller decrypts it back into a regular Secret.
  • External Secrets Operator — an external secret store like Vault, AWS Secrets Manager, GCP Secret Manager, or Azure Key Vault holds the real secret values, and this operator syncs those values into K8s Secrets. The common answer in operations.

In an operational cluster you almost always use one or more of these three together. In this chapter we cover only the manifest shape and injection methods, and the deep part of secret-value operation (a comparison of sealed-secrets vs external-secrets vs SOPS, and zero-password operation combined with IRSA) is covered in Chapter 29 secret operations.

How does it apply when config changes #

Having made ConfigMap · Secret, the next question naturally comes up — when you change a value, does that change reflect into the Pod automatically? The answer splits by injection method. It’s a part often confused in operations, so it’s worth organizing once.

When injected as an environment variable — once at start time #

A value poured into an environment variable via env or envFrom is filled once when the Pod starts, and that’s it. Editing the ConfigMap afterward doesn’t change the environment variables of an already-running Pod. A process’s environment block, once made at start time, is frozen in that shape at the OS level — it’s not a K8s-only limit but the nature of the process model.

To re-fill environment variables with new values, the Pod has to come up fresh. The standard command that forces an intended gradual replacement is the following one line.

gradual Pod replacement to reflect config
kubectl rollout restart deployment/web

This command works by the same mechanism as the rolling update of Chapter 4 — except it leaves the spec unchanged and replaces only the Pods gradually. The newly started Pod begins with the ConfigMap’s latest values as environment variables.

(kubectl rollout restart is a standard command introduced from 1.15+. Before that, a workaround of slightly changing the spec by adding an arbitrary annotation to the Pod label was used, but that’s hardly touched anymore.)

When mounted as a volume — auto-updated #

A ConfigMap · Secret mounted as a volume is synced periodically by K8s so the file updates automatically. Editing the ConfigMap usually changes the file content inside the container within a delay on the order of minutes. This delay is tied to the kubelet’s sync period (default 1 minute), so it isn’t immediate.

But one caveat is attached — the change is only meaningful if the app has code to re-read that file. A process that re-reads its config on SIGHUP, like nginx, works, but an app that reads config once at start and is done won’t change behavior even if the file changes. A pattern of placing a sidecar that detects config-file changes and triggers an auto-reload (e.g., configmap-reload) is also commonly seen.

Organizing into one table #

Injection shapeChange reflectionForce update
env / envFromnot reflected automatically (once at start)kubectl rollout restart
volumeauto-reflected (delay on the order of minutes)the app’s own reload or kubectl rollout restart

A simple operational basic — once you’ve changed config, just run an intended gradual replacement once with kubectl rollout restart. Whether environment variable or volume, it’s surely reflected at that moment.

Cleanup and teardown #

Clean up the objects we made today.

clean up everything
kubectl delete -f web.yaml
kubectl delete -f web-config.yaml
kubectl delete -f db-secret.yaml
example output
deployment.apps "web" deleted
configmap "web-config" deleted
secret "db-secret" deleted

Confirm it’s empty with kubectl get deploy,cm,secret and you’re back at the starting point. The kube-root-ca.crt ConfigMap and the default-token-... Secret are objects the cluster holds for itself, so it’s normal for one line of each to remain.

Exercises #

  1. As in the body, change web-config.yaml’s LOG_LEVEL value from "info" to "debug" and kubectl apply. Record separately how the LOG_LEVEL environment variable of an already-running Pod shows (kubectl exec deploy/web -- env | grep LOG_LEVEL) and how the /etc/myapp/app.conf file shows at the same moment. Then run kubectl rollout restart deployment/web and note how the two outputs change, against the table in §“How does it apply when config changes.”
  2. Confirm how the plaintext value written in db-secret.yaml’s stringData is represented in the kubectl get secret db-secret -o yaml output, and restore the original value with kubectl get secret db-secret -o jsonpath='{.data.DB_PASSWORD}' \| base64 -d. Organize the meaning of “base64 is not encryption” in a paragraph in your own words, and briefly compare the three options in §“To handle real secret values safely” for what’s available for real protection in operations.
  3. Change web-config.yaml’s app.conf key name to a dotless name like app_conf, then in the Deployment write just the one line envFrom: [configMapRef: ...] so all keys become environment variables. Confirm how app_conf came into the kubectl exec deploy/web -- env output (and how it shows since its value is multiline text once it does come in), and rewrite the decision rule of §“When to use which” to fit your own hand.

In one line: ConfigMap and Secret are the two objects that solve 12-factor’s “store config in the environment” in K8s, the standard shape for separating per-environment values and secret values from the manifest body. There are three paths to inject into a Pod — env (single key) · envFrom (all) · volume (file) — and each has a different change-reflection model. A Secret’s default behavior is merely base64 encoding, not encryption — real protection comes from a separate layer like etcd encryption, Sealed Secrets, or the External Secrets Operator.

Next chapter #

Even this far, one thing still sits awkwardly — every object we’ve made so far (Pod, Deployment, Service, ConfigMap, Secret) all went into the default namespace. If several environments (dev / staging) or several teams’ workloads must be up together in one cluster, this single space soon gets cramped. And the labels we’ve kept meeting since Chapter 4’s selector have piled up enough by now to organize once.

In Chapter 7 Namespace and labels we follow how a namespace logically splits a cluster, the syntax of labels · selectors and commonly-used label conventions, and operational tips for handling kubectl per namespace, closing Part 1 with the shape of cleanly splitting the 7 manifest kinds covered in Part 1 within one cluster.

X