Skip to content
Documentation Background

ConfigMaps & Secrets

Most applications consist of two parts:

  1. The Application Binary and its
  2. Configuration.

How you package those two parts together determines how painful your multi-environment deployments will be.


Anti-pattern: hardcoded images per environment vs best practice: single base image with runtime injection

The anti-pattern is embedding configuration directly inside the container image. It forces you to maintain a separate image per environment (dev, staging, prod) — each with its own build, its own repository slot, and its own deployment pipeline. A one-line config change means rebuilding and redeploying everything.

The Kubernetes approach: build one hardened base image with no embedded environment config, store it once, and inject the correct configuration at run time. The same image runs in every environment — only the configuration resource changes.

Coupled (anti-pattern)Decoupled (Kubernetes way)
Images per environment3 (dev, staging, prod)1
Config change requiresFull rebuild + redeployUpdate config resource only
Security scanningPer-imageOne image
”Works in dev, fails in prod” riskHigh (image drift)Low (same image everywhere)

Kubernetes provides two first-class API objects for this:

  • ConfigMaps — non-sensitive configuration (hostnames, ports, feature flags, config files)
  • Secrets — sensitive data (passwords, TLS certificates, API keys)

Before reaching ConfigMaps and Secrets, you can configure a container directly in the pod manifest — overriding the image’s default command, arguments, and environment variables without rebuilding the image.

Every Docker image bakes in a default executable (ENTRYPOINT) and optional default arguments (CMD). The pod manifest exposes two fields that map directly to these:

Dockerfile directivePod manifest fieldWhat it does
ENTRYPOINTcommandThe executable that runs
CMDargsDefault arguments to that executable

Override only the arguments (keep the image’s executable):

spec:
containers:
- name: app
image: my-app:1.0
args: ["--listen-port", "9090"] # replaces the image's default CMD

Override the entire command (change the executable itself):

spec:
containers:
- name: app
image: my-app:1.0
command: ["node", "--cpu-prof", "--heap-prof", "app.js"]

Both command and args accept a list of strings. You can write them as YAML arrays (["a", "b"]) or as block lists:

command:
- node
- --cpu-prof
- app.js

Env vars are declared per container (there is no pod-level global env block). All values must be strings — quote numbers and booleans.

spec:
containers:
- name: app
image: my-app:1.0
env:
- name: APP_ENV
value: "production"
- name: MAX_CONNECTIONS
value: "100" # must be quoted — YAML would parse 100 as an integer

Variable referencing with $(VAR_NAME):

Kubernetes lets env vars reference other env vars defined in the same container using $(NAME):

env:
- name: POD_NAME
value: "web-server"
- name: LOG_PREFIX
value: "$(POD_NAME)-log" # resolves to "web-server-log"
- name: LISTEN_PORT
value: "8080"
args:
- --port
- $(LISTEN_PORT) # also works in command/args

Rules for $(VAR_NAME) resolution:

RuleDetail
Order mattersThe referenced var must be declared before the var that uses it
Manifest-onlyCannot reference vars baked into the image (e.g., NODE_VERSION from a Node.js base image)
UnresolvedIf the reference can’t be resolved, it stays as the literal string $(VAR_NAME) — no error
EscapingUse $$(VAR_NAME) to pass the literal dollar-paren string without resolution

To reference image-baked or OS-level variables, run the command through a shell — the shell resolves them at runtime using standard $VAR syntax:

command:
- sh
- -c
- 'echo "Running on $HOSTNAME"; sleep infinity' # shell resolves $HOSTNAME

A ConfigMap is a v1 core API object that stores non-sensitive configuration data as a flat map of key-value pairs. Containers consume it as environment variables, startup arguments, or mounted files — without any Kubernetes-specific application code.

PropertyDetail
API group / versionv1 (core)
Structuredata block (plain text) and optional binaryData block (Base64)
No spec or statusConfigMaps hold data only, not operational state
Key formatAlphanumeric, dashes -, dots ., underscores _ only
Size limit1 MiB — use external config stores for larger payloads
NamespacedYes — only visible to pods in the same namespace
Store in ConfigMapsStore in Secrets instead
Database hostnames and portsPasswords and credentials
Feature flagsTLS certificates and private keys
Config files (nginx.conf, app.yaml)API keys and OAuth tokens
Service namesAny cryptographically sensitive data
Account names (non-sensitive)
ConfigMaps

Imperative — from literal values:

Terminal window
kubectl create configmap app-config \
--from-literal=db_host=postgres.prod.svc \
--from-literal=db_port=5432

Imperative — from files:

Terminal window
# File name becomes the key; file contents become the value
kubectl create configmap nginx-config --from-file=nginx.conf
# Custom key name (instead of using the filename)
kubectl create configmap nginx-config --from-file=server-config=nginx.conf
# Entire directory — every file in the dir becomes a key (subdirs/symlinks ignored)
kubectl create configmap app-config --from-file=config/

Imperative — from an env file (key=value lines):

Terminal window
kubectl create configmap app-config --from-env-file=app.env

Declarative — data for text, binaryData for binary:

apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
db_host: "postgres.prod.svc"
db_port: "5432"
nginx.conf: | # pipe (|) = literal block — entire block is one string value
server {
listen 80;
server_name _;
location / {
proxy_pass http://backend:8080;
}
}
binaryData:
icon.png: iVBORw0KGgo... # Base64-encoded binary — kubectl handles this automatically
Terminal window
kubectl apply -f app-config.yaml
kubectl get configmap app-config -o yaml
kubectl describe configmap app-config

Bind a specific ConfigMap key to a named environment variable:

spec:
containers:
- name: app
image: my-app:1.0
env:
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: app-config # ConfigMap name
key: db_host # key to extract
optional: true # if true, container starts even if CM or key is missing
- name: DB_PORT
valueFrom:
configMapKeyRef:
name: app-config
key: db_port

Bulk-inject all keys from a ConfigMap as environment variables:

spec:
containers:
- name: app
image: my-app:1.0
envFrom:
- prefix: APP_ # optional: prepend APP_ to every key → APP_DB_HOST, APP_DB_PORT
configMapRef:
name: app-config
optional: true # container starts even if the ConfigMap doesn't exist

envFrom behaviour rules:

ScenarioWhat happens
Two ConfigMaps with the same keyLast one listed wins
A key conflicts with an env block entryThe explicit env entry always takes precedence
Keys with invalid env var charactersSilently skipped — use configMapKeyRef for those keys
ConfigMaps Volume

Each key becomes a separate file inside the mounted directory:

spec:
volumes:
- name: config-vol
configMap:
name: app-config
containers:
- name: app
image: nginx
volumeMounts:
- name: config-vol
mountPath: /etc/app-config

Result: /etc/app-config/db_host, /etc/app-config/db_port, /etc/app-config/nginx.conf

For advanced volume mechanics — atomic symlink updates, selective key projection, subPath caveats, and optional flag — see Volumes & Storage → ConfigMap Volumes.

MethodUpdates propagate?Best for
configMapKeyRef❌ No (static at startup)Specific key → named env var, custom var name
envFrom❌ No (static at startup)Bulk-inject all keys, prefix namespacing
Volume mount✅ Yes (1–2 min)Config files, hot-reload, multi-file configs

By default, a missing ConfigMap or key blocks the container from starting (the pod still schedules and other containers may start). Add optional: true to allow the container to start with the variable simply unset.

Updating a ConfigMap while pods are running risks a split-brain: pods that restarted after the change pick up the new config, while older replicas still run the old one. To prevent this, lock the ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-v1
data:
db_host: "postgres.prod.svc"
immutable: true # API server rejects any further data changes

Once immutable is set, you cannot change the data — only delete the object. To roll out a new config:

  1. Create a new ConfigMap with a version suffix (app-config-v2)
  2. Update the pod template’s configMapRef or configMapKeyRef to point to the new name
  3. Rolling restart picks up the new config cleanly

Secrets store sensitive data using the same key-value structure as ConfigMaps but are handled differently: distributed only to nodes that need them, and stored in memory via tmpfs so they never touch disk.

Secret fieldConfigMap equivalentDescription
databinaryDataBase64-encoded values
stringDatadataPlain-text values — write-only (converted to data on apply)
immutableimmutableLocks the object from further data changes
type(no equivalent)Declares the Secret category for validation
Secrets Data vs StringData

Why Secrets Aren’t Truly Secure (Out of the Box)

Section titled “Why Secrets Aren’t Truly Secure (Out of the Box)”
TypeRequired keysUse case
OpaqueAnyDefault — arbitrary user credentials
kubernetes.io/tlstls.crt, tls.keyTLS certificates for Ingress and services
kubernetes.io/dockerconfigjson.dockerconfigjsonPrivate container registry pull credentials
kubernetes.io/dockercfg.dockercfgLegacy Docker registry format
kubernetes.io/basic-authusername, passwordHTTP Basic authentication
kubernetes.io/ssh-authssh-privatekeySSH private key
kubernetes.io/service-account-tokentoken, ca.crt, namespaceServiceAccount tokens
bootstrap.kubernetes.io/tokentoken-id, token-secretNode bootstrapping

Kubernetes validates that a Secret’s keys match the requirements for the declared type.

Imperative — generic (Opaque):

Terminal window
kubectl create secret generic db-creds \
--from-literal=username=admin \
--from-literal=password=S3cur3P@ss!

Imperative — TLS:

Terminal window
kubectl create secret tls app-tls \
--cert=server.crt \
--key=server.key

Imperative — Docker registry pull secret:

Terminal window
kubectl create secret docker-registry registry-creds \
--docker-server=registry.example.com \
--docker-username=deployer \
--docker-password=token123 \
--docker-email=ci@example.com
# Or import directly from local Docker config
kubectl create secret docker-registry registry-creds \
--from-file=$HOME/.docker/config.json

Use a pull secret in a pod via spec.imagePullSecrets:

spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: app
image: registry.example.com/my-private-app:1.0

Declarative — data block (pre-encoded Base64 values):

Encode the value first, then paste the output into the manifest:

Terminal window
# Encode a value
echo -n 'S3cur3P@ss!' | base64
# S3N1cjNQQHNzIQ==
# Decode to verify
echo -n 'S3N1cjNQQHNzIQ==' | base64 --decode
# S3cur3P@ss!
apiVersion: v1
kind: Secret
metadata:
name: db-creds
type: Opaque
data:
password: S3N1cjNQQHNzIQ== # Base64-encoded — must be pre-encoded manually

Declarative — stringData (plain text, auto-encoded on apply):

apiVersion: v1
kind: Secret
metadata:
name: db-creds
type: Opaque
stringData:
username: admin # Kubernetes base64-encodes this on apply
password: S3cur3P@ss!

Dry-run trick — generate a Secret manifest without manual Base64 encoding:

Terminal window
kubectl create secret generic db-creds \
--from-literal=username=admin \
--from-literal=password=S3cur3P@ss! \
--dry-run=client -o yaml > db-creds.yaml
spec:
containers:
- name: app
image: my-app:1.0
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-creds
key: password
optional: true
spec:
containers:
- name: app
image: my-app:1.0
envFrom:
- secretRef:
name: db-creds

⚠️ Security risks of injecting Secrets via environment variables

Section titled “⚠️ Security risks of injecting Secrets via environment variables”

Avoid injecting Secrets into env vars in production:

RiskExplanation
Log exposureMany runtimes dump env vars to stdout on startup or crash — credentials land in your logging pipeline
Child process inheritanceEvery child process spawned by the container inherits all env vars — third-party scripts get your secrets
Recommended alternativeMount Secrets as volume files instead
Secret Volume Mount
spec:
volumes:
- name: secret-vol
secret:
secretName: db-creds
containers:
- name: app
image: my-app:1.0
volumeMounts:
- name: secret-vol
mountPath: /etc/db-creds
readOnly: true # always read-only for Secrets
Terminal window
kubectl exec api-pod -- cat /etc/db-creds/password
# S3cur3P@ss! (decoded from Base64 at mount time)

For advanced Secret volume mechanics — tmpfs storage, file permissions, defaultMode, fsGroup for non-root processes — see Volumes & Storage → Secret Volumes.

Create Secret → Stored in etcd → Pod scheduled → kubelet transfers Secret
(plain text) (unencrypted to a node to node over the network
by default)
→ container runtime mounts Secret via tmpfs → App reads plain text
(Base64 decoded at mount time; from /etc/<mount-path>
never written to disk)
→ Pod deleted → kubelet wipes tmpfs replica from node memory
Secret Limitations
LimitationDetail
Base64 ≠ encryptionAnyone with API access can decode instantly
Unencrypted etcdRequires explicit EncryptionConfiguration to fix
RBAC over-permissionA single misconfigured role grants cross-namespace Secret reads
No auto-rotationVolume-mounted files update, but the app must re-read them; env vars never update
No audit by defaultStandard Kubernetes logs don’t track Secret reads
LayerMechanismWhat it protects
Encryption at restEncryptionConfiguration + KMS providerSecrets stored encrypted in etcd
Encryption in transitService mesh (mutual TLS)Node-to-node and control-plane traffic
Access controlLeast-privilege RBACLimits who can read/modify Secret objects
Node isolationAvoid privileged containers; secure etcd nodesPrevents host-path access to cached data
External vaultHashiCorp Vault / cloud KMS + Secrets Store CSI DriverSecrets stored outside Kubernetes; injected at runtime
Sealed SecretsBitnami Sealed Secrets — asymmetric encryptionEncrypted manifests safe to commit to git; only in-cluster controller can decrypt
External Secrets OperatorESO — syncs from HashiCorp Vault, AWS SM, GCP SM, etc.Centralised external secret management; avoids storing secrets in etcd

ConfigMaps and Secrets handle configuration you define before deployment. The Downward API solves a different problem: injecting metadata that is only known after the pod is scheduled — such as the pod’s own IP, the node it ran on, or its resource limits.

The Downward API is not a REST endpoint your application calls. The kubelet reads the pod’s live metadata and projects it directly into the container as environment variables or files.

To project Downward API data as files (required for labels/annotations, or file-reading sidecars), see Volumes & Storage → Downward API Volumes.

Reference typeWhat it exposes
fieldRefPod-level metadata: name, namespace, IP, node name, labels, annotations
resourceFieldRefContainer-level compute resources: CPU/memory requests and limits
Field pathInject as env?Inject as file?Description
metadata.namePod name
metadata.namespaceNamespace
metadata.uidPod UID
metadata.labels['<key>']Value of a single label
metadata.labelsAll labels as key="value" lines
metadata.annotations['<key>']Value of a single annotation
metadata.annotationsAll annotations as key="value" lines
spec.nodeNameWorker node name
spec.serviceAccountNameServiceAccount name
status.podIP / status.podIPsPod IP address(es)
status.hostIP / status.hostIPsNode IP address(es)

Injecting Pod Metadata as Environment Variables

Section titled “Injecting Pod Metadata as Environment Variables”
spec:
containers:
- name: app
image: my-app:1.0
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name # resolves to the pod's name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
Terminal window
# Verify resolved values at runtime
kubectl exec <pod-name> -- env | grep -E 'POD_|NODE_'

Some runtimes (JVM, Node.js) need to know their allocated CPU and memory to tune thread pools and garbage collection. Use resourceFieldRef:

env:
- name: MEM_LIMIT_MIB
valueFrom:
resourceFieldRef:
resource: limits.memory
divisor: 1Mi # divide raw bytes by 1Mi → reports value in MiB
- name: CPU_LIMIT_MILLICORES
valueFrom:
resourceFieldRef:
resource: limits.cpu
divisor: 1m # reports CPU in millicores

Divisor reference:

UnitDivisorReports
Bytes (default)1Raw byte count
Kibibytes1KiValue ÷ 1,024
Mebibytes1MiValue ÷ 1,048,576
Millicores (CPU)1mCPU in millicores (e.g., 500m500)

Cross-container reference — one container reading another’s limits:

env:
- name: SIDECAR_MEM_LIMIT
valueFrom:
resourceFieldRef:
containerName: envoy-sidecar # reference a different container's resources
resource: limits.memory
divisor: 1Mi

SymptomLikely causeDiagnosticFix
Pod stuck Pending with configmap not foundConfigMap doesn’t exist in the namespacekubectl get configmap -n <ns>Create the ConfigMap before the pod
Container blocked from startingRequired ConfigMap/key missing; optional: true not setkubectl describe pod → EventsAdd optional: true or create the missing resource
Env var is empty or wrong valueWrong key name in configMapKeyRefkubectl exec -- envVerify key matches the ConfigMap
Config change not reflected in running containerUsing env vars (static injection)Check injection methodSwitch to volume mount, or rolling restart
Trailing whitespace causes escaped output in ConfigMapFile had trailing spaceskubectl get cm -o yaml — look for \n in valuesStrip trailing whitespace from source files
$(VAR_NAME) appears as a literal stringReferencing a var defined after it in the manifestCheck env block orderMove the referenced var above the referencing var
Secret value is garbleddata field not properly Base64-encodedDecode: echo "<val>" | base64 -dUse stringData and let Kubernetes encode
Downward API env var shows wrong valueField path typo or unsupported fieldkubectl describe podCheck supported fields table above
Secret not updating in env var after CM changeEnv vars are static — only volumes updatekubectl exec -- envRolling restart: kubectl rollout restart deployment/<name>