ConfigMaps from literals and files, Secret types, env vars vs volume mounts, and immutable ConfigMaps.
In production, you never hardcode configuration into your container image. The same image should run in dev, staging, and production -- the only thing that changes is the configuration. Kubernetes solves this with two resources: ConfigMaps for non-sensitive config and Secrets for sensitive data like passwords and API keys.
┌─────────────────────────────────────────────────────────────┐
│ Same Container Image │
│ myapp:v2.1.0 │
└──────────┬──────────────────┬──────────────────┬────────────┘
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ DEV │ │ STAGING │ │ PROD │
│ │ │ │ │ │
│ DB_HOST= │ │ DB_HOST= │ │ DB_HOST= │
│ localhost │ │ staging-db │ │ prod-db │
│ LOG_LEVEL= │ │ LOG_LEVEL= │ │ LOG_LEVEL= │
│ debug │ │ info │ │ warn │
│ DB_PASS= │ │ DB_PASS= │ │ DB_PASS= │
│ devpass │ │ stg-s3crt │ │ pr0d!Pa$s │
└─────────────┘ └─────────────┘ └─────────────┘
ConfigMap ConfigMap ConfigMap
+ Secret + Secret + Secret
This is the 12-factor app principle: store config in the environment, not in code. Your image is built once, tested once, and promoted through environments. Only the ConfigMaps and Secrets change.
Hardcoding config causes real problems:
ConfigMaps and Secrets decouple configuration from the container image. You inject config at runtime, not build time.
A ConfigMap holds non-sensitive key-value configuration data. Think database hostnames, feature flags, log levels, config files -- anything that is not a secret.
kubectl create configmap app-config \
--from-literal=DB_HOST=postgres \
--from-literal=DB_PORT=5432 \
--from-literal=LOG_LEVEL=info
# configmap/app-config created
kubectl get configmap app-config -o yaml
# apiVersion: v1
# kind: ConfigMap
# metadata:
# name: app-config
# namespace: default
# data:
# DB_HOST: postgres
# DB_PORT: "5432"
# LOG_LEVEL: info
# Suppose you have an nginx config file:
# nginx.conf contains your custom nginx configuration
kubectl create configmap nginx-conf --from-file=nginx.conf
# configmap/nginx-conf created
# The filename becomes the key, file contents become the value:
kubectl describe configmap nginx-conf
# Name: nginx-conf
# Data:
# ====
# nginx.conf:
# ----
# worker_processes auto;
# events { worker_connections 1024; }
# http { ... }
kubectl create configmap nginx-conf --from-file=my-nginx.conf=nginx.conf
# Key is "my-nginx.conf" instead of "nginx.conf"
# Given a directory with multiple config files:
# config-dir/
# database.properties
# cache.properties
# feature-flags.json
kubectl create configmap app-settings --from-file=config-dir/
# configmap/app-settings created
# Each file in the directory becomes a key in the ConfigMap
For anything you want to track in git, write the ConfigMap as YAML.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: default
data:
# Simple key-value pairs
DB_HOST: postgres
DB_PORT: "5432"
LOG_LEVEL: info
CACHE_TTL: "300"
# Multi-line value (an entire config file)
app.properties: |
server.port=8080
server.context-path=/api
spring.datasource.url=jdbc:postgresql://postgres:5432/mydb
spring.jpa.hibernate.ddl-auto=validate
# Another config file
nginx.conf: |
worker_processes auto;
events {
worker_connections 1024;
}
http {
server {
listen 80;
location / {
proxy_pass http://localhost:8080;
}
}
}
kubectl apply -f app-config.yaml
# configmap/app-config created
Tip: ConfigMaps also have a
binaryDatafield for binary content (base64-encoded). Use it for binary config files like Java keystores. Most of the time you will usedata.
There are two ways to consume a ConfigMap: as environment variables or as mounted files. Both are common and serve different purposes.
Use valueFrom.configMapKeyRef to inject a single key from a ConfigMap into an environment variable:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:v1
env:
- name: DATABASE_HOST
valueFrom:
configMapKeyRef:
name: app-config
key: DB_HOST
- name: DATABASE_PORT
valueFrom:
configMapKeyRef:
name: app-config
key: DB_PORT
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: app-config
key: LOG_LEVEL
optional: true # Pod starts even if key is missing
Use envFrom to inject every key from a ConfigMap as an environment variable:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:v1
envFrom:
- configMapRef:
name: app-config
prefix: CFG_ # optional: all keys get this prefix
With prefix: CFG_, the key DB_HOST becomes the env var CFG_DB_HOST inside the container.
# Verify env vars are set inside the container
kubectl exec deploy/myapp -- env | sort
# CFG_DB_HOST=postgres
# CFG_DB_PORT=5432
# CFG_LOG_LEVEL=info
# CFG_CACHE_TTL=300
# ...
Gotcha:
envFromskips keys that are not valid environment variable names (keys with dashes, dots, etc.). If your ConfigMap has a key likeapp.properties, it won't become an env var. Use volume mounts for config files.
Mount a ConfigMap as a volume, and each key becomes a file in the mount path:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
volumeMounts:
- name: config-volume
mountPath: /etc/nginx/conf.d
readOnly: true
volumes:
- name: config-volume
configMap:
name: nginx-conf
kubectl apply -f deployment-volume.yaml
# Check the mounted files
kubectl exec deploy/nginx -- ls /etc/nginx/conf.d
# nginx.conf
kubectl exec deploy/nginx -- cat /etc/nginx/conf.d/nginx.conf
# worker_processes auto;
# events {
# worker_connections 1024;
# }
# ...
You can also mount a single key to a specific file path:
volumes:
- name: config-volume
configMap:
name: app-config
items:
- key: nginx.conf
path: default.conf # mounted as /etc/nginx/conf.d/default.conf
This is a critical behavior to understand -- it comes up in interviews and real operations.
┌────────────────────────────────────────────────────────────────┐
│ ConfigMap Update Behavior │
│ │
│ Environment Variables Volume Mounts subPath Mounts │
│ ────────────────────── ───────────────── ────────────── │
│ NOT updated Updated NOT updated │
│ automatically automatically automatically │
│ (~60-90 seconds) │
│ │
│ Requires Pod restart kubelet syncs Requires Pod │
│ to pick up changes periodically restart │
└────────────────────────────────────────────────────────────────┘
# Update the ConfigMap
kubectl edit configmap app-config
# Change LOG_LEVEL from "info" to "debug"
# Wait ~60 seconds, then check the mounted file
kubectl exec deploy/myapp -- cat /etc/config/LOG_LEVEL
# debug ← updated automatically!
# But the env var is still the old value
kubectl exec deploy/myapp -- printenv LOG_LEVEL
# info ← NOT updated (env vars are set at Pod start)
Gotcha: If you mount a ConfigMap using
subPath, the file does NOT get automatic updates. ThesubPathmount creates a direct bind mount instead of using a symlink, so the kubelet's update mechanism is bypassed. This catches people by surprise. If you need live updates, don't usesubPath.
containers:
- name: nginx
image: nginx:1.25
volumeMounts:
- name: config-volume
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf # this file will NOT auto-update
volumes:
- name: config-volume
configMap:
name: nginx-conf
Secrets are structurally similar to ConfigMaps but intended for sensitive data: passwords, API tokens, TLS certificates, SSH keys.
| Type | Use Case |
|---|---|
Opaque |
Generic key-value pairs (default) |
kubernetes.io/tls |
TLS certificate + private key |
kubernetes.io/dockerconfigjson |
Docker registry credentials |
kubernetes.io/basic-auth |
Username + password |
kubernetes.io/ssh-auth |
SSH private key |
kubernetes.io/service-account-token |
ServiceAccount token (auto-created) |
kubectl create secret generic db-creds \
--from-literal=username=admin \
--from-literal=password='s3cret!P@ss'
# secret/db-creds created
kubectl get secret db-creds -o yaml
# apiVersion: v1
# kind: Secret
# metadata:
# name: db-creds
# type: Opaque
# data:
# password: czNjcmV0IVBAc3M= ← base64 encoded
# username: YWRtaW4= ← base64 encoded
# Create a secret from a file (e.g., an API key file)
kubectl create secret generic api-key --from-file=api-key.txt
# secret/api-key created
kubectl create secret tls my-tls-cert \
--cert=tls.crt \
--key=tls.key
# secret/my-tls-cert created
kubectl get secret my-tls-cert -o yaml
# apiVersion: v1
# kind: Secret
# metadata:
# name: my-tls-cert
# type: kubernetes.io/tls
# data:
# tls.crt: LS0tLS1CRUdJTi... ← base64 encoded certificate
# tls.key: LS0tLS1CRUdJTi... ← base64 encoded private key
kubectl create secret docker-registry regcred \
--docker-server=ghcr.io \
--docker-username=myuser \
--docker-password=ghp_xxxxxxxxxxxx \
--docker-email=me@example.com
# secret/regcred created
Secrets in YAML can use either data (base64-encoded values) or stringData (plain text that gets encoded on apply).
apiVersion: v1
kind: Secret
metadata:
name: db-creds
type: Opaque
data:
username: YWRtaW4= # echo -n "admin" | base64
password: czNjcmV0IVBAc3M= # echo -n "s3cret!P@ss" | base64
# Encode values yourself:
echo -n "admin" | base64
# YWRtaW4=
echo -n "s3cret!P@ss" | base64
# czNjcmV0IVBAc3M=
# Decode to verify:
echo "YWRtaW4=" | base64 -d
# admin
apiVersion: v1
kind: Secret
metadata:
name: db-creds
type: Opaque
stringData:
username: admin
password: "s3cret!P@ss"
Tip:
stringDatais write-only. When youkubectl get secret -o yaml, Kubernetes always showsdata(base64-encoded). You can mixdataandstringDatain the same Secret --stringDatavalues overridedatavalues for the same key.
Secrets are consumed the same way as ConfigMaps: environment variables or volume mounts.
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:v1
env:
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: db-creds
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-creds
key: password
kubectl apply -f deployment-secret-env.yaml
# Verify the secrets are available inside the container
kubectl exec deploy/myapp -- printenv DB_USERNAME
# admin
kubectl exec deploy/myapp -- printenv DB_PASSWORD
# s3cret!P@ss
Each key in the Secret becomes a file. The file contents are the decoded (plain text) value.
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:v1
volumeMounts:
- name: db-creds
mountPath: /etc/secrets
readOnly: true
volumes:
- name: db-creds
secret:
secretName: db-creds
defaultMode: 0400 # read-only for owner
kubectl apply -f deployment-secret-volume.yaml
# Each key is a file in /etc/secrets
kubectl exec deploy/myapp -- ls /etc/secrets
# password
# username
kubectl exec deploy/myapp -- cat /etc/secrets/username
# admin
kubectl exec deploy/myapp -- cat /etc/secrets/password
# s3cret!P@ss
Tip: Set
defaultMode: 0400on Secret volumes so only the container's user can read the files. This is a security best practice.
When your images are stored in a private registry, Pods need credentials to pull them. Use imagePullSecrets:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
imagePullSecrets:
- name: regcred # the docker-registry Secret
containers:
- name: myapp
image: ghcr.io/myorg/myapp:v1
You can also attach imagePullSecrets to a ServiceAccount so every Pod using that ServiceAccount gets them automatically:
kubectl patch serviceaccount default \
-p '{"imagePullSecrets": [{"name": "regcred"}]}'
# serviceaccount/default patched
# Now all Pods in this namespace using the "default" SA
# can pull from ghcr.io without specifying imagePullSecrets
This is the single most important security concept in this module. base64 is encoding, not encryption. Anyone can decode it.
# "Decrypting" a Kubernetes Secret:
echo "czNjcmV0IVBAc3M=" | base64 -d
# s3cret!P@ss
# That's it. No key, no password, no algorithm. Just decode.
┌──────────────────────────────────────────────────────────────┐
│ Secret Security Layers │
│ │
│ Layer 1: RBAC │
│ ├── Restrict who can read Secrets │
│ ├── kubectl get secret → requires "get" verb on "secrets" │
│ └── Separate roles for ConfigMaps vs Secrets │
│ │
│ Layer 2: etcd Encryption at Rest │
│ ├── By default, Secrets stored in etcd in plain text! │
│ ├── Enable EncryptionConfiguration on the API Server │
│ └── Uses AES-CBC or AES-GCM encryption │
│ │
│ Layer 3: Namespace Isolation │
│ ├── Secrets are namespaced — can't access across namespaces │
│ └── Use RBAC to limit namespace access │
│ │
│ Layer 4: External Secret Management │
│ ├── Don't store Secrets in git (even base64!) │
│ ├── Use Sealed Secrets, Vault, AWS Secrets Manager │
│ └── Secrets are synced at runtime, not stored in manifests │
│ │
│ Layer 5: Audit Logging │
│ └── Enable audit logs to track who accesses Secrets │
└──────────────────────────────────────────────────────────────┘
1. Enable etcd encryption at rest. By default, kubectl get secret -o yaml and direct etcd access both expose secrets in plain text. Configure the API Server with an EncryptionConfiguration:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {} # fallback: read unencrypted secrets
2. Use RBAC to restrict Secret access. Not every developer needs to kubectl get secret:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: secret-reader
namespace: production
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
resourceNames: ["db-creds"] # only this specific secret
3. Never commit Secrets to git. Not even base64-encoded. Use .gitignore, git-secrets, or external secret management tools.
Gotcha: If a Secret was ever committed to git, rotating the credential is not enough -- the old value lives in git history forever. You must rotate the credential AND scrub the git history (or accept the risk). Prevention is much easier than remediation.
Kubernetes lets you mark ConfigMaps and Secrets as immutable. Once set, the data cannot be changed -- you must delete and recreate the resource.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-v3
data:
DB_HOST: prod-postgres.internal
LOG_LEVEL: warn
immutable: true
kubectl apply -f immutable-configmap.yaml
# configmap/app-config-v3 created
# Try to modify it:
kubectl edit configmap app-config-v3
# error: configmaps "app-config-v3" is immutable
# Must delete and recreate:
kubectl delete configmap app-config-v3
kubectl apply -f immutable-configmap.yaml
apiVersion: v1
kind: Secret
metadata:
name: db-creds-v2
type: Opaque
stringData:
username: admin
password: "n3w-s3cret"
immutable: true
app-config-v3) and update Deployments to reference the new version. This gives you clean rollbacks.Tip: A common pattern is to append a hash or version to ConfigMap names (
app-config-abc123). Tools like Helm and Kustomize do this automatically. When the config changes, a new ConfigMap is created with a new name, and the Deployment is updated to reference it -- triggering a rolling update.
For production clusters, storing Secret manifests (even base64-encoded) in git is not acceptable. Several tools solve this problem.
Sealed Secrets uses asymmetric encryption. You encrypt a Secret locally with a public key, and only the controller in the cluster can decrypt it with the private key.
YOU CLUSTER
│ │
Secret YAML ──┤ │
│ kubeseal │
├────────▶ SealedSecret YAML ──┤
│ (safe for git!) │
│ ├──▶ SealedSecret Controller
│ │ │
│ │ decrypts with
│ │ private key
│ │ │
│ │ ▼
│ │ creates regular
│ │ Secret in cluster
# Install kubeseal CLI and the controller
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets -n kube-system
# Seal a secret
kubectl create secret generic db-creds \
--from-literal=password=s3cret \
--dry-run=client -o yaml | kubeseal -o yaml > sealed-secret.yaml
# The sealed-secret.yaml is safe to commit to git!
kubectl apply -f sealed-secret.yaml
# The controller decrypts it and creates the real Secret
The External Secrets Operator syncs secrets from external providers (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Azure Key Vault) into Kubernetes Secrets.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-creds
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: db-creds # the K8s Secret to create
data:
- secretKey: password # key in the K8s Secret
remoteRef:
key: prod/database # path in AWS Secrets Manager
property: password # field within the secret
The operator reads prod/database from AWS Secrets Manager and creates a regular Kubernetes Secret named db-creds. It re-syncs every hour.
Vault provides dynamic secrets, leasing, and revocation. Integration options include:
Tip: For most teams, start with Sealed Secrets (simplest to set up). Move to External Secrets Operator when you need central secret management across multiple clusters. Use Vault when you need dynamic secrets, secret rotation, and audit trails.
Let's put everything together. We will create a ConfigMap and Secret, deploy an application that uses both via environment variables and volume mounts, then update the ConfigMap and observe live propagation.
apiVersion: v1
kind: ConfigMap
metadata:
name: webapp-config
data:
APP_ENV: production
APP_PORT: "8080"
LOG_LEVEL: info
config.json: |
{
"features": {
"dark_mode": true,
"beta_api": false
},
"cache_ttl": 300
}
kubectl apply -f app-config.yaml
# configmap/webapp-config created
apiVersion: v1
kind: Secret
metadata:
name: webapp-secret
type: Opaque
stringData:
DB_USERNAME: webapp_user
DB_PASSWORD: "p@ssw0rd!2024"
API_KEY: "sk-abc123def456ghi789"
kubectl apply -f app-secret.yaml
# secret/webapp-secret created
This Deployment uses both env vars and volume mounts:
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
spec:
replicas: 2
selector:
matchLabels:
app: webapp
template:
metadata:
labels:
app: webapp
spec:
containers:
- name: webapp
image: busybox:1.36
command: ["/bin/sh", "-c"]
args:
- |
echo "=== Environment Variables ==="
echo "APP_ENV=$APP_ENV"
echo "LOG_LEVEL=$LOG_LEVEL"
echo "DB_USERNAME=$DB_USERNAME"
echo "DB_PASSWORD is set: $([ -n "$DB_PASSWORD" ] && echo yes || echo no)"
echo ""
echo "=== Mounted Config File ==="
cat /etc/config/config.json
echo ""
echo "=== Mounted Secret Files ==="
ls -la /etc/secrets/
echo ""
echo "App running. Sleeping..."
while true; do sleep 3600; done
# Environment variables from ConfigMap
envFrom:
- configMapRef:
name: webapp-config
# Environment variables from Secret
env:
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: webapp-secret
key: DB_USERNAME
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: webapp-secret
key: DB_PASSWORD
# Volume mounts
volumeMounts:
- name: config-files
mountPath: /etc/config
readOnly: true
- name: secret-files
mountPath: /etc/secrets
readOnly: true
volumes:
- name: config-files
configMap:
name: webapp-config
items:
- key: config.json
path: config.json
- name: secret-files
secret:
secretName: webapp-secret
defaultMode: 0400
kubectl apply -f webapp-deployment.yaml
# deployment.apps/webapp created
# Wait for Pods to be ready
kubectl rollout status deployment/webapp
# deployment "webapp" successfully rolled out
# Check the logs to see the config in action
kubectl logs deploy/webapp
# === Environment Variables ===
# APP_ENV=production
# LOG_LEVEL=info
# DB_USERNAME=webapp_user
# DB_PASSWORD is set: yes
#
# === Mounted Config File ===
# {
# "features": {
# "dark_mode": true,
# "beta_api": false
# },
# "cache_ttl": 300
# }
#
# === Mounted Secret Files ===
# total 0
# lrwxrwxrwx 1 root root 15 ... API_KEY -> ..data/API_KEY
# lrwxrwxrwx 1 root root 18 ... DB_PASSWORD -> ..data/DB_PASSWORD
# lrwxrwxrwx 1 root root 18 ... DB_USERNAME -> ..data/DB_USERNAME
#
# App running. Sleeping...
# Update the ConfigMap -- change LOG_LEVEL and the config.json feature flag
kubectl patch configmap webapp-config --type merge -p '{
"data": {
"LOG_LEVEL": "debug",
"config.json": "{\n \"features\": {\n \"dark_mode\": true,\n \"beta_api\": true\n },\n \"cache_ttl\": 600\n}"
}
}'
# configmap/webapp-config patched
# Wait ~60 seconds for the kubelet to sync the mounted file
sleep 60
# The mounted file has the NEW value:
kubectl exec deploy/webapp -- cat /etc/config/config.json
# {
# "features": {
# "dark_mode": true,
# "beta_api": true ← changed!
# },
# "cache_ttl": 600 ← changed!
# }
# But the environment variable still has the OLD value:
kubectl exec deploy/webapp -- printenv LOG_LEVEL
# info ← still "info", not "debug"
# To pick up env var changes, you need to restart the Pods:
kubectl rollout restart deployment/webapp
# deployment.apps/webapp restarted
kubectl exec deploy/webapp -- printenv LOG_LEVEL
# debug ← now updated
kubectl delete deployment webapp
kubectl delete configmap webapp-config
kubectl delete secret webapp-secret
# deployment.apps "webapp" deleted
# configmap "webapp-config" deleted
# secret "webapp-secret" deleted
| Feature | ConfigMap | Secret |
|---|---|---|
| Purpose | Non-sensitive config | Sensitive data |
| Data format | Plain text | base64-encoded (or stringData) |
| Size limit | 1 MiB | 1 MiB |
| Env vars | configMapKeyRef / configMapRef |
secretKeyRef |
| Volume mount | configMap: volume |
secret: volume |
| Automatic updates | Volume mounts: yes, env vars: no | Volume mounts: yes, env vars: no |
| Encryption at rest | No (not needed) | No by default (must enable) |
| RBAC | Same as other resources | Should be restricted |
Progress through each section in order, or jump to where you need practice.
Practice individual concepts you just learned.
Combine concepts and learn patterns. Each challenge has multiple variants at different difficulties.
configMapKeyRef for single keys, envFrom for all keys) or as mounted volumes (each key becomes a file).stringData in Secret YAML to write plain text values -- Kubernetes encodes them for you on apply.imagePullSecrets let Pods pull from private container registries. Attach them to ServiceAccounts for namespace-wide access.immutable: true) prevent changes, improve kubelet performance at scale, and encourage versioned config patterns.