Configuration
One image, multiple environments — and what "Secret" does not mean.
ConfigMaps and Secrets
A container image must be deployable in development, staging, and production without being rebuilt. This is the principle behind this entire lesson, and it is not theoretical: if the database URL is baked into the image, then the artifact you test is not the one you ship, and the test no longer proves anything.
The other half of the problem is more unpleasant. A password placed in an image stays there permanently: layers are immutable, deleting it in a later layer does not remove it from the history, and anyone with access to the registry can read it. A secret shipped in an image is not a secret; it is a leak on a delay.
Kubernetes therefore separates two objects for two purposes: the ConfigMap holds non-sensitive configuration, and the Secret holds confidential data. Both are injected into containers in the same ways — environment variables or mounted files — but the cluster treats them differently.
One warning, placed right away and before any syntax, because it is the most widespread misunderstanding in the ecosystem: a Kubernetes Secret is not encrypted by default. It is base64-encoded, which is a transport format, not a protection. We will look at exactly what this implies, and what you need to add for the word “secret” to be earned.
The ConfigMap
A ConfigMap is a dictionary of key-value pairs, or a set of files, stored in the cluster.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: atelier
data:
# Simple values: one key, one string
APP_ENV: "production"
LOG_LEVEL: "info"
API_TIMEOUT: "30"
FEATURE_NOUVEAU_PANIER: "true"
# Whole file: the key becomes the file name when mounted as a volume
application.properties: |
server.port=8080
spring.jpa.show-sql=false
logging.level.root=INFO
nginx.conf: |
server {
listen 80;
location /api/ {
proxy_pass http://api:8080/;
}
}
Two syntax details that cost time when ignored. First, all values in data are strings: API_TIMEOUT: 30 without quotes is rejected by the schema, and true without quotes is interpreted as a YAML boolean. Get into the habit of always quoting. Second, YAML’s | preserves line breaks, which lets you embed a configuration file as-is.
The imperative variants are handy for exploration:
# From literal values
kubectl create configmap app-config \
--from-literal=APP_ENV=production \
--from-literal=LOG_LEVEL=info
# From a file: the key is the file name
kubectl create configmap nginx-config --from-file=nginx.conf
# From an entire directory: one key per file
kubectl create configmap tous-configs --from-file=./config/
# From a .env file: one key per line
kubectl create configmap app-config --from-env-file=.env
kubectl get configmaps
kubectl describe configmap app-config # shows values in plain text
kubectl get configmap app-config -o yaml
The Secret
Structurally identical, with one extra field and an expected encoding.
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: atelier
type: Opaque
stringData:
# stringData: you write in plain text, Kubernetes encodes on save
DB_USER: "app_user"
DB_PASSWORD: "S3cr3t-a-changer"
DATABASE_URL: "postgresql://app_user:S3cr3t-a-changer@postgres:5432/appdb"
stringData is almost always the right choice for a hand-written manifest: it saves you from encoding values yourself. The data field, on the other hand, expects values that are already base64-encoded:
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
data:
DB_USER: YXBwX3VzZXI= # echo -n 'app_user' | base64
DB_PASSWORD: UzNjcjN0LWEtY2hhbmdlcg==
# Imperative creation: encoding is done for you
kubectl create secret generic db-credentials \
--from-literal=DB_USER=app_user \
--from-literal=DB_PASSWORD='S3cr3t-a-changer'
# From a file (certificate, private key, credentials file)
kubectl create secret generic tls-keys --from-file=./server.key
# Secret dedicated to private registries, for imagePullSecrets
kubectl create secret docker-registry registre-prive \
--docker-server=registry.example.com \
--docker-username=deploiement \
--docker-password='jeton-du-registre'
The most commonly used Secret types:
type | Usage |
|---|---|
Opaque | arbitrary data: the general case |
kubernetes.io/dockerconfigjson | authentication to a private image registry |
kubernetes.io/tls | certificate and private key, consumed by Ingresses |
kubernetes.io/service-account-token | service account token |
What “Secret” actually protects
Here is the demonstration to run once so you never forget it:
# The "protected" content
kubectl get secret db-credentials -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
# S3cr3t-a-changer
No password, no key, no decryption. Base64 has never been a security mechanism — it exists to carry binary bytes in a text field. Concretely, this means:
- Anyone allowed to read the Secrets in a namespace reads all the passwords in that namespace in plain text.
- A Secret committed to Git is a compromised secret, even if it is base64-encoded.
- By default, Secrets are stored in plain text in etcd. A poorly protected etcd backup exposes everything.
Three added measures are what make a Secret truly confidential:
- RBAC. This is the only protection that works by default: restricting who can read Secrets. A Secret is only confidential if the read permissions are.
- Encryption at rest. It is enabled on the API server through an
EncryptionConfiguration, with a local key or a key management service (KMS). It is not enabled out of the box on a self-managed cluster; managed offerings often provide it, so check case by case. - An external manager. HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, with an operator that syncs to the cluster. Rotation becomes possible and auditing exists, which Kubernetes alone does not provide.
Compared with an environment variable written in a Deployment manifest, a Secret still brings two real gains: the value does not appear in kubectl describe deployment, and it can be managed separately from the deployment. That is progress, not a guarantee.
Injecting through environment variables
The most direct method, key by key:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 2
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: mon-api:1.0.0
ports:
- containerPort: 8080
env:
# A ConfigMap key to a variable, with optional renaming
- name: ENVIRONNEMENT
valueFrom:
configMapKeyRef:
name: app-config
key: APP_ENV
# A Secret key to a variable
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: DB_PASSWORD
# A literal value, for anything neither shared nor sensitive
- name: PORT
value: "8080"
To import everything at once, without listing each key:
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: db-credentials
envFrom is concise, but it has a trade-off worth weighing: variable names become implicit. Reading the Deployment, you can no longer tell which variables the application receives. For an application whose code you do not control, explicit listing remains preferable.
Finally, add optional: true when a key may legitimately be missing; otherwise the Pod stays stuck in CreateContainerConfigError:
- name: FONCTION_EXPERIMENTALE
valueFrom:
configMapKeyRef:
name: app-config
key: FEATURE_NOUVEAU_PANIER
optional: true
The major limitation of environment variables
It is structural: environment variables are fixed when the process starts. Modifying a ConfigMap changes nothing in Pods that are already running, and triggers no redeployment. You end up with a cluster where the ConfigMap says one thing and the application does another — a silent inconsistency that is very hard to spot.
There are two possible answers, depending on your needs.
Force a redeployment on every change by making the template depend on a fingerprint of the configuration:
metadata:
labels:
app: api
annotations:
# To be recomputed on every release: any change to the template
# triggers a rolling update of the Pods.
checksum/config: "9f2b7c1e4a"
Or, more simply in day-to-day operations:
# Rolling restart of the Deployment, without changing the image
kubectl rollout restart deployment/api
Injecting through volumes
Mounting a ConfigMap as a directory gives you one file per key. This is the required mode for an entire configuration file, and the only one that allows an update without a restart.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
ports:
- containerPort: 80
volumeMounts:
# All keys of the ConfigMap become files here
- name: config-volume
mountPath: /etc/app/config
readOnly: true
# A mounted Secret: restrictive permissions, never in a log
- name: secrets-volume
mountPath: /etc/app/secrets
readOnly: true
volumes:
- name: config-volume
configMap:
name: app-config
items:
# Without "items", all keys are mounted.
# With it, you choose precisely, and you rename.
- key: nginx.conf
path: default.conf
- name: secrets-volume
secret:
secretName: db-credentials
defaultMode: 0400 # read-only for the owner
With this manifest, the container sees /etc/app/config/default.conf containing the value of the nginx.conf key. Renaming through items is what lets you place a file exactly where the application expects it, with no contortions.
To replace a single file in an existing directory without hiding the rest of its contents, subPath is the key:
volumeMounts:
- name: config-volume
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf # replaces ONE file, not the directory
Without subPath, mounting on /etc/nginx/conf.d/ would hide the entire original contents of the directory. This is a classic cause of a container refusing to start after a configuration is added.
Hot updates, and their conditions
ConfigMaps mounted as volumes are updated in the running container, without a restart. There are three caveats to know:
- Propagation takes up to about a minute, depending on the kubelet’s sync period.
- The application must know how to reload its file. nginx needs an
nginx -s reload, and a Spring Boot application does not re-read its properties on its own. The file changes, the behavior does not. - A mount using
subPathis never updated. This is the exact trade-off of that option: mount precision in exchange for losing reloads.
# Edit a ConfigMap in place, then observe the file in the Pod
kubectl edit configmap app-config
kubectl exec -it deploy/web -- cat /etc/app/config/default.conf
Environment variables or volume: which to choose
| Criterion | Environment variables | Volume |
|---|---|---|
| Simple, short values | suitable | verbose |
| Complete configuration file | unsuitable | only option |
| Update without restart | impossible | possible, with caveats |
Visibility in /proc and crash logs | exposed | limited to readers of the file |
Output of kubectl describe pod | variable names visible | mount path only |
For secrets, the volume is safer, for a concrete reason you can verify yourself:
# The main process's environment is readable in /proc.
kubectl exec -it deploy/api -- sh -c 'tr "\0" "\n" < /proc/1/environ | grep DB_'
# DB_PASSWORD=S3cr3t-a-changer
Environment variables also end up in exception traces and crash reports, and are inherited by every child process. A file mounted with mode 0400 has a much narrower exposure surface.
Immutable ConfigMaps
Marking a ConfigMap or a Secret as immutable forbids any later modification, and also reduces the watch load on the API server in large clusters.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-v3
immutable: true
data:
APP_ENV: "production"
LOG_LEVEL: "warn"
This enforces a useful discipline: rather than modifying app-config, you create app-config-v4 and point the Deployment at it. A configuration change becomes a versioned modification of the Deployment, so it is visible in kubectl rollout history and can be reverted with kubectl rollout undo. A ConfigMap modified in place, by contrast, leaves no usable trace.
Key takeaways
- An image should be independent of its environment. The ConfigMap carries the configuration and the Secret carries the sensitive data, and both can be injected through environment variables or through a volume.
- A Secret is not encrypted: it is base64-encoded, readable with a simple
base64 -d, and stored in plain text in etcd by default. What really protects it is RBAC, encryption at rest configured on the API server side, and an external secrets manager. - Environment variables are fixed at startup: changing a ConfigMap does nothing to running Pods. Force the switch with a checksum annotation or a
kubectl rollout restart. - Volumes are updated in place (within about a minute), provided the application reloads its file — and never with
subPath, which freezes the mount. - Prefer volumes for secrets: environment variables end up in
/proc, in error traces, and in all child processes.
Tip: version your ConfigMaps (
app-config-v3) and make them immutable. A configuration change then becomes a Deployment revision, and is therefore traceable and reversible like any other deployment.
Comments
Comments are powered by GitHub Discussions
Sign in with GitHub to join the discussion