Skip to main content

Kubernetes ConfigMaps and Secrets: Configure Apps Without Rebuilds

Configure apps with Kubernetes ConfigMaps, mount TLS keys from Secrets, see why Secrets do not belong in env vars, and roll out config changes safely.

TLDR: A ConfigMap holds settings and a Secret holds credentials, keys and certificates. Pods use them as environment variables or as files in a volume. Mounted files update in running pods within about a minute; environment variables only change when the pod restarts. Secrets are only base64-encoded, not encrypted, so keep them out of Git, give them to apps as files rather than environment variables, and limit who can read them.

In this part you configure podinfo and its Valkey cache with ConfigMaps, give podinfo a TLS certificate from a Secret, watch an environment variable leak a token, change the configuration of running pods, rotate the certificate, and let Kustomize roll out config changes for you.

Prerequisites

  • The podinfo Deployment from Part 4 and its Service from Part 5. On a new cluster: kubectl apply -f 04-deployments/deployment.yaml -f 05-networking/service.yaml.
  • openssl and jq on your computer.

ConfigMaps

Keeping configuration out of the image means one image runs in every environment, and a settings change does not need a new build. A ConfigMap is a set of keys; each value is either a short string or a whole file:

apiVersion: v1
kind: ConfigMap
metadata:
  name: podinfo-config
data:
  # Single values, used as environment variables
  PODINFO_UI_MESSAGE: Hello from a ConfigMap
  PODINFO_LEVEL: info
  # A whole file, mounted into the container
  features.yaml: |
    newCheckout: false
    banner: Autumn sale

A pod can consume a ConfigMap in three ways:

How Field in the pod spec Changes reach a running pod?
One key as an environment variable env[].valueFrom.configMapKeyRef No, only after a restart
All keys as environment variables envFrom[].configMapRef No, only after a restart
Keys as files in a volume volumes[].configMap Yes, after about a minute

envFrom turns every key into a variable, including keys you did not mean to expose, such as features.yaml here. Pick keys explicitly with env when a ConfigMap mixes values and files. A ConfigMap can hold up to 1 MiB of data; it is not a place for large files.

Most programs also read their own configuration files, and a ConfigMap is a good way to ship them. The Valkey cache, which podinfo uses from now on, gets its valkey.conf from one:

# Valkey's own configuration file, from a ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: cache-config
data:
  valkey.conf: |
    maxmemory 64mb
    maxmemory-policy allkeys-lru
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cache
  labels:
    app: cache
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cache
  template:
    metadata:
      labels:
        app: cache
    spec:
      containers:
        - name: valkey
          image: valkey/valkey:9.1.2-alpine
          args: ['/etc/valkey/valkey.conf'] # start valkey-server with this file
          ports:
            - name: valkey
              containerPort: 6379
          volumeMounts:
            - name: config
              mountPath: /etc/valkey
              readOnly: true
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 256Mi
      volumes:
        - name: config
          configMap:
            name: cache-config
---
apiVersion: v1
kind: Service
metadata:
  name: cache
  labels:
    app: cache
spec:
  selector:
    app: cache
  ports:
    - name: valkey
      port: 6379
      targetPort: valkey

Secrets

A Secret looks like a ConfigMap, but Kubernetes treats it with more care: kubelets keep mounted Secrets in memory (tmpfs) rather than on disk, RBAC rules often allow ConfigMaps but not Secrets, and kubectl describe does not print their values. Secrets have types; Opaque is the general one, and kubernetes.io/tls, kubernetes.io/dockerconfigjson (registry credentials) and kubernetes.io/basic-auth check that the right keys are present.

podinfo can serve HTTPS when it finds tls.crt and tls.key in a directory. Create a self-signed certificate for this guide and store it as a TLS Secret (in production, cert-manager issues and renews real certificates into Secrets like this one):

The companion repository's .gitignore excludes tls.key and tls.crt. Never commit a private key, and never commit a Secret manifest with real values in it: anyone who can read the repository can read the Secret.

Base64 Is Not Encryption

The values in a Secret are base64-encoded so that binary data fits into JSON and YAML. Anyone who can read the Secret object can decode them. The first 40 characters of the encoded key already give away what it is:

LS0tLS1CRUdJTiBQUklWQVRF is base64 for -----BEGIN PRIVATE. Inside the cluster, Secrets are also stored in etcd unencrypted unless someone turned on encryption at rest; Part 10 reads one straight out of etcd. Protect Secrets with RBAC, encryption at rest and an external secret store, below. See also how to decode a Kubernetes Secret.

Do Not Put Secrets in Environment Variables

Kubernetes can put a Secret value into an environment variable with secretKeyRef, and many tutorials do. It is convenient and it works, but environment variables leak: crash reports and error pages include them, child processes inherit them, kubectl exec ... env prints them, and many apps have a debug endpoint that dumps them. podinfo has one, /env. Here is the leak with a throwaway token:

# The pattern to avoid: a Secret in an environment variable of an app that prints its environment
apiVersion: v1
kind: Pod
metadata:
  name: env-leak
spec:
  containers:
    - name: podinfo
      image: ghcr.io/stefanprodan/podinfo:6.15.0
      env:
        - name: API_TOKEN
          valueFrom:
            secretKeyRef:
              name: api-token
              key: token

Anyone who can send an HTTP request to that pod now has the token. Mount Secrets as files instead, with the volume readOnly, and have the app read the file. Files do not show up in the environment, you can restrict them with file permissions (Part 10 does), and they update in place when the Secret changes.

Warning

Files are not a cure-all. Some apps, podinfo among them, also have endpoints that print their config files, or log connection strings that contain passwords. Read what your app exposes and logs, keep debug endpoints off the network (Part 5 routes only / and /version), and prefer credentials that are not part of a URL. That is also why this guide's cache runs without a password and relies on a NetworkPolicy in Part 10: podinfo can only take a cache password inside its connection URL, and it logs that URL whenever the cache is unreachable.

Use Them in the Deployment

The new podinfo Deployment reads two values from the ConfigMap as environment variables, mounts features.yaml from the ConfigMap as a file, mounts the TLS Secret as files, serves HTTPS on port 8443 with them, and uses the cache:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: podinfo
  labels:
    app: podinfo
spec:
  replicas: 3
  revisionHistoryLimit: 5
  progressDeadlineSeconds: 120
  minReadySeconds: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: podinfo
  template:
    metadata:
      labels:
        app: podinfo
    spec:
      containers:
        - name: podinfo
          image: ghcr.io/stefanprodan/podinfo:6.15.0
          command: ['./podinfo']
          args:
            - --config-path=/podinfo/config # the ConfigMap file
            - --secure-port=8443 # HTTPS, with the key pair from the Secret
            - --cert-path=/podinfo/tls
            - --cache-server=tcp://cache:6379
          env:
            # Non-secret settings, one key from the ConfigMap each
            - name: PODINFO_UI_MESSAGE
              valueFrom:
                configMapKeyRef:
                  name: podinfo-config
                  key: PODINFO_UI_MESSAGE
            - name: PODINFO_LEVEL
              valueFrom:
                configMapKeyRef:
                  name: podinfo-config
                  key: PODINFO_LEVEL
          ports:
            - name: http
              containerPort: 9898
            - name: https
              containerPort: 8443
          volumeMounts:
            - name: config
              mountPath: /podinfo/config
              readOnly: true
            - name: tls # the Secret, as files: tls.crt and tls.key
              mountPath: /podinfo/tls
              readOnly: true
          readinessProbe:
            httpGet:
              path: /readyz
              port: http
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 10
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 128Mi
      volumes:
        - name: config
          configMap:
            name: podinfo-config
            items: # only this key becomes a file
              - key: features.yaml
                path: features.yaml
        - name: tls
          secret:
            secretName: podinfo-tls

Check what the pods got. Port-forward the Service for HTTP and one pod for HTTPS, keeping their process IDs so you can stop exactly these two later:

The mounted files are symlinks into a hidden ..data directory. Kubernetes updates a ConfigMap or Secret volume by writing the new files into a new directory and switching the ..data link in one step, so an app never reads half of an update. curl --cacert tls.crt succeeds because the certificate covers localhost and curl trusts the self-signed certificate you gave it.

The cache works, and Valkey runs with the settings from its ConfigMap:

Change the Configuration of Running Pods

Edit 06-config/configmap.yaml: change the message to Hello again, set newCheckout: true and change the banner to Winter sale. kubectl diff shows the change before you apply it:

The mounted file changes in the running pods on its own. The kubelet checks for updates periodically, so it takes up to about a minute; this loop measures it:

The file changed after about a minute (55 seconds in this run), but the message did not: it comes from an environment variable, which is set once when the container starts. Restart the pods to pick it up. A port-forward stays attached to the pod it started with, so stop both and start the HTTP one again (the 2>/dev/null hides the error if one has already exited with its pod):

Two more things to know about updates:

  • A file mounted with subPath never updates. Mount the whole volume, as here, if the app should see changes.
  • A file changing does not mean the app uses it. podinfo re-reads its config directory, but many apps read a file once at startup. Restart them, or let Kustomize do it (below).

Rotate the Certificate

Create a new key pair and replace the Secret's contents. kubectl create ... --dry-run=client -o yaml | kubectl apply -f - is the usual way to update a Secret from files or literals (more ways to update a Secret):

The warning appears because the Secret was created with kubectl create, not kubectl apply; kubectl adds the missing annotation and carries on. podinfo loads its certificate once at startup, like most servers, so restart it and check which certificate it now serves:

Wait for the rollout before you start the port-forward, so it connects to a new pod. curl now trusts the server only because it presents the new certificate (--cacert tls.crt is the new file), and the served serial number matches it.

Immutable ConfigMaps and Secrets

Set immutable: true on a ConfigMap or Secret whose data should never change in place. The API server then rejects updates to its data, and kubelets stop watching it, which takes load off the API server in large clusters:

apiVersion: v1
kind: ConfigMap
metadata:
  name: release-info
immutable: true # the data can no longer change; delete and recreate instead
data:
  version: '2026.10'

To change it, create a new ConfigMap with a new name and point the Deployment at it. That pattern, a new name for every change, is exactly what Kustomize automates.

Roll Out Config Changes with Kustomize

Kustomize is built into kubectl (kubectl apply -k, kubectl kustomize). Its configMapGenerator creates a ConfigMap whose name ends with a hash of its contents and rewrites every reference to it. Change the contents and the name changes, so the Deployment's pod template changes too, and Kubernetes rolls out new pods by itself:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
configMapGenerator:
  # Creates podinfo-config-<hash>; the hash changes whenever the data changes
  - name: podinfo-config
    literals:
      - PODINFO_UI_MESSAGE=Hello from Kustomize
      - PODINFO_LEVEL=info
    files:
      - features.yaml

The ConfigMap and all three references in the Deployment use the same generated name. Now change the message in kustomization.yaml to Hello again from Kustomize and render it again:

kubectl kustomize 06-config/kustomize | grep "name: podinfo-config"
  name: podinfo-config-mk64bbhbkk
              name: podinfo-config-mk64bbhbkk
              name: podinfo-config-mk64bbhbkk
          name: podinfo-config-mk64bbhbkk

A new hash, so kubectl apply -k 06-config/kustomize would roll out the change, and the old ConfigMap would remain for a rollback. Helm charts get the same effect with a checksum annotation on the pod template. The Helm vs Kustomize comparison covers when to use which. Stop the port-forwards before you go on:

kill $PF $PF_TLS

Secrets in Production

Kubernetes Secrets are a delivery mechanism, not a vault. In production:

  • Turn on encryption at rest for Secrets in etcd, ideally with a KMS provider. Managed services encrypt etcd for you; check whether they also offer envelope encryption with your own key. Part 10 shows why.
  • Limit access with RBAC. get or list on Secrets in a namespace means reading all of them, and so does permission to create pods there, because a pod can mount any Secret in its namespace.
  • Keep the source of truth outside the cluster. The External Secrets Operator (API external-secrets.io/v1) copies values from Vault, AWS Secrets Manager, Google Secret Manager, Azure Key Vault and others into Kubernetes Secrets and refreshes them. The Secrets Store CSI Driver mounts them as files without creating Secret objects at all.
# External Secrets Operator: copies a value from an external store into a Kubernetes Secret
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: api-token
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault # a ClusterSecretStore that points at your secret store
  target:
    name: api-token # the Secret it creates and keeps up to date
  data:
    - secretKey: token
      remoteRef:
        key: demo/api
        property: token
  • For GitOps, commit encrypted Secrets with Sealed Secrets or SOPS, or commit only ExternalSecret objects like the one above.
  • Rotate credentials on a schedule, and restart or reload the apps that read them at startup.

Common Mistakes

  • Secrets in Git. Base64 is not encryption; a committed Secret is a leaked Secret. Rotate it, do not just delete the commit.
  • Secrets in environment variables of apps that print their environment. Mount them as files.
  • Expecting environment variables to update. They are fixed at container start; restart, or use files.
  • subPath mounts. They never receive updates.
  • Editing a ConfigMap and forgetting the Deployment. Use generated names (Kustomize) or a checksum annotation (Helm) so config changes roll out.

Summary

  • ConfigMaps hold settings and files; Secrets hold credentials, keys and certificates, base64-encoded but not encrypted.
  • Pods use them as environment variables (fixed at start) or files (updated in place within about a minute).
  • Give Secrets to apps as read-only files, keep debug endpoints off the network, and protect Secrets with RBAC and encryption at rest.
  • Immutable ConfigMaps and Kustomize's generated names turn config changes into normal rollouts.

Keep Learning

Next, in Part 7, the cache loses its data, and you fix that with a volume and a StatefulSet.

Updated:

Found an issue?