We closed module 2 with a list of debts, and the first of them was this one: the API URL is baked into the web-store image. Every time we want to deploy the store to rutas-norte-pre instead of rutas-norte-pro we have to build a different image, with the same code and a single character changed in a configuration file. That breaks the most basic promise of the container: build once, deploy anywhere. The object that fixes it is the ConfigMap, a store of non-confidential configuration that lives in the cluster, is versioned in Git alongside the rest of the manifests and is injected into the container at start-up. In this lesson you will build the web-store ConfigMap with its real nginx.conf and mount it as a file inside the container, which is the most powerful way to consume it and the one that produces the fewest surprises. Injection as environment variables is covered in depth in Environment Variables; here we mention it only to place it on the map.
Contents
- Why configuration is separated from code
- Anatomy of the ConfigMap object
- The four ways to create a ConfigMap
- The three ways to consume it (and which one we cover here)
- Mounting a ConfigMap as a volume
- Selecting specific keys with
itemsanddefaultMode subPath: what it is for and its two dangers- The central case: the
nginx.confofweb-store - Updating a ConfigMap and propagation to volumes
- Immutable ConfigMaps
- The 1 MiB limit and what to do if you exceed it
- Per-environment ConfigMaps in Rutas Norte
- Why configuration is separated from code
The twelve-factor methodology (The Twelve-Factor App), written in 2011 for cloud applications, devotes its third factor to exactly this problem, and its statement is a single sentence:
Store configuration in the environment. Configuration is everything that can vary between deployments; code is what does not vary.
The practical test it proposes is blunt, and it is worth applying it to Rutas Norte: could you make the code repository public right now without leaking a single credential? Today the answer is no, because the bookings-postgres password is written in a manifest in the repository. That part is settled by the Secrets lesson. But there is a second, quieter test: is it exactly the same image that runs in rutas-norte-dev, in rutas-norte-pre and in rutas-norte-pro? Today it is not, either.
This is not purism. It has very concrete consequences:
| With configuration inside the image | With configuration outside the image |
|---|---|
One image per environment: web-store:2.5.0-dev, -pre, -pro |
A single web-store:2.5.0 image for all three environments |
What you test in pre is not what ships to pro |
What was validated in pre is the exact artefact that gets promoted |
| Changing a timeout means rebuilding and going through the pipeline again (minutes or hours) | Changing a timeout means editing an object in the cluster (seconds) |
| Production configuration lives in the image registry, unreadable | Configuration lives in YAML, reviewable in a pull request |
| A configuration rollback forces you to rewind the image | A configuration rollback is independent of the code |
The two concrete cases Rutas Norte is dragging along:
- The API URL in
web-store. The SPA needs to know which address to call:http://bookings-apiinside the cluster,https://api.rutasnorte.examplefrom the customer's browser. Today it is baked into the compiled JavaScript inside the image. - The
nginx.confofweb-store. The server configuration — compression, security headers, the fallback rule toindex.htmlthat every SPA needs, the proxy towardsbookings-api— is baked into the image. Tweaking the cache of the static assets requires rebuilding it.
Both are solved today, in this lesson.
- Anatomy of the ConfigMap object
A ConfigMap is one of the simplest objects in Kubernetes. It has neither spec nor status: only metadata and data.
apiVersion: v1 # ConfigMap lives in the core group, no prefix
kind: ConfigMap
metadata:
name: web-store-config
namespace: rutas-norte-dev
labels:
app: web-store
app.kubernetes.io/part-of: rutas-norte
environment: dev
data: # key -> TEXT value pairs (UTF-8)
API_URL: "http://bookings-api:8080"
LOG_LEVEL: "debug"
nginx.conf: | # the value can be a whole file
server {
listen 80;
}
binaryData: # key -> BINARY value pairs (base64)
favicon.ico: AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAA...The points that define the object:
apiVersion: v1: it belongs to the core group, like Pod, Service or Namespace. It carries no group prefix.- It has no
spec. A ConfigMap does not describe a desired state that a controller has to reconcile; it is a passive data container. That is also why it has nostatus. datacontains valid UTF-8 text. The keys are potential file names, so they must be limited to alphanumeric characters,-,_and..binaryDatacontains data that is not text, base64-encoded when written into the manifest. Kubernetes decodes it when mounting. It is useful for a certificate in DER format, an.ico, a font, a small.tar.gzof templates.- The same key cannot appear in both
dataandbinaryData: theapplyfails. - It is namespaced. A pod can only mount ConfigMaps from its own namespace. There is no native way to reference one from another namespace, and that restriction is what makes the "one ConfigMap per environment" pattern of section 12 feel natural.
One important detail about multi-line keys. YAML's | is a literal block: it preserves line breaks as they are. That is what you want for a configuration file. Its cousin > (folded block) turns single line breaks into spaces, and would ruin an nginx.conf. For files, always use |.
- The four ways to create a ConfigMap
3.1. YAML manifest
This is the canonical form and the only one that versions well in Git. You have already seen it above. It is applied like any other object:
3.2. --from-literal
For short values, above all when you are exploring or generating the YAML with --dry-run.
kubectl create configmap web-store-config \
--from-literal=API_URL=http://bookings-api:8080 \
--from-literal=LOG_LEVEL=debug \
-n rutas-norte-devLet us check what it created exactly:
apiVersion: v1
data:
API_URL: http://bookings-api:8080
LOG_LEVEL: debug
kind: ConfigMap
metadata:
creationTimestamp: "2026-08-05T09:14:22Z"
name: web-store-config
namespace: rutas-norte-dev
resourceVersion: "184213"
uid: 6f2b1c4a-3d9e-4a71-9f0c-2b8e5d417a63Notice that it carries no labels at all. Objects created with kubectl create do not follow the Rutas Norte labelling scheme, and that leaves them out of every query with -l app.kubernetes.io/part-of=rutas-norte. That is why the recommended use of the imperative command is as a YAML generator, not as a way of creating objects:
kubectl create configmap web-store-config \
--from-literal=API_URL=http://bookings-api:8080 \
--dry-run=client -o yaml > k8s/base/web-store/configmap.yamlYou then edit the labels by hand and apply the file. It is the same --dry-run=client -o yaml flow from module 1, now applied to configuration.
3.3. --from-file with a single file
Here is the interesting case. You have the real nginx.conf in your repository and you want to put it into a ConfigMap without copying it by hand into a YAML (with the risk of ruining the indentation).
kubectl create configmap web-store-nginx \
--from-file=k8s/base/web-store/nginx.conf \
--dry-run=client -o yaml -n rutas-norte-devapiVersion: v1
data:
nginx.conf: |
server {
listen 80;
server_name www.rutasnorte.example;
...
}
kind: ConfigMap
metadata:
creationTimestamp: null
name: web-store-nginx
namespace: rutas-norte-devThe key is the base name of the file (nginx.conf), not the full path. If you want the key to have a different name, you prepend it with an =:
kubectl create configmap web-store-nginx \
--from-file=default.conf=k8s/base/web-store/nginx-production.conf \
--dry-run=client -o yaml -n rutas-norte-devThe key will be default.conf even though the file on disk is called nginx-production.conf. This trick is indispensable when the name the application expects does not match the one that is convenient in the repository.
3.4. --from-file with a directory
If you point at a directory, each file inside it becomes a key. It is not recursive: subdirectories are ignored.
kubectl create configmap web-store-nginx \
--from-file=k8s/base/web-store/conf.d/ \
--dry-run=client -o yaml -n rutas-norte-devapiVersion: v1
data:
compression.conf: |
gzip on;
gzip_types text/css application/javascript application/json;
security.conf: |
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
spa.conf: |
location / {
try_files $uri $uri/ /index.html;
}
kind: ConfigMap
metadata:
creationTimestamp: null
name: web-store-nginxThree files, three keys. When we mount this ConfigMap in /etc/nginx/conf.d the three files will appear with those same names, which is exactly what nginx expects.
3.5. --from-env-file
This is the one that confuses people most. It takes a file in KEY=value format and creates one key per line, not one key holding the whole file.
kubectl create configmap web-store-config \
--from-env-file=k8s/environments/dev/web-store.env \
--dry-run=client -o yaml -n rutas-norte-devapiVersion: v1
data:
API_URL: http://bookings-api:8080
LOG_LEVEL: debug
TIMEOUT_MS: "5000"
kind: ConfigMap
metadata:
creationTimestamp: null
name: web-store-configCompare that with what --from-file would have done to the same file: a single key called web-store.env whose value would be the entire text. The difference is enormous and it is the source of a classic mistake.
| Option | On app.env it produces |
Typical use |
|---|---|---|
--from-file=app.env |
1 key: app.env → whole contents |
Mounting the file as it is |
--from-env-file=app.env |
N keys: one per line | Feeding environment variables with envFrom |
Rules of the --from-env-file format: blank lines and lines starting with # are ignored; quotes are not accepted around the value (they would be stored as part of the value), nor are line continuations; and the keys must be valid environment variable names.
3.6. Summary table
| Form | Command | When to use it |
|---|---|---|
| Manifest | kubectl apply -f cm.yaml |
Always in production. It is what gets versioned |
| Literal | --from-literal=K=V |
Quick tests and YAML generation |
| File | --from-file=path/f.conf |
Long configuration files |
| Directory | --from-file=path/dir/ |
A set of files that belong together |
| Env file | --from-env-file=app.env |
Many simple variables at once |
- The three ways to consume it (and which one we cover here)
flowchart LR
CM["ConfigMap<br/>web-store-config"] --> A["Environment variables<br/>env / envFrom"]
CM --> B["File in a volume<br/>volumeMounts"]
CM --> C["Command-line arguments<br/>args with $(VAR)"]
A --> P["Container"]
B --> P
C --> P
| Form | Advantage | Drawback | Where it is covered |
|---|---|---|---|
| Environment variable | Simple, universal | Does not refresh without restarting the pod | 03-03 |
| File in a volume | Updates live, accepts whole files | The app must read from disk | This lesson |
Argument (args) |
Explicit in the manifest | Fixed from start-up | 03-03 |
This lesson focuses on the second one because it is the one that solves the nginx.conf case — a 40-line file does not fit comfortably into an environment variable — and because it is the only one that lets you change configuration without recreating pods.
- Mounting a ConfigMap as a volume
The mechanism always has two halves that must line up: a volume at pod level that declares where the data comes from, and a volumeMount at container level that declares where it appears. The bridge between the two is the name field.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-store
namespace: rutas-norte-dev
spec:
replicas: 3
selector:
matchLabels:
app: web-store # only app and environment in the selector
environment: dev
template:
metadata:
labels:
app: web-store
app.kubernetes.io/name: web-store
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
containers:
- name: nginx
image: nginx:1.27.1-alpine # immutable tag, never latest
ports:
- containerPort: 80
volumeMounts:
- name: nginx-config # (2) must match volumes[].name
mountPath: /etc/nginx/conf.d # where it appears inside the container
readOnly: true # good practice: the app must not write
volumes:
- name: nginx-config # (1) name of the volume inside the pod
configMap:
name: web-store-nginx # ConfigMap from the SAME namespaceWhat happens exactly when that pod starts:
- The kubelet reads the pod and sees a volume of type
configMap. - It asks the API for the
web-store-nginxConfigMap in therutas-norte-devnamespace. If it does not exist, the pod stays inContainerCreatinguntil it appears, with aFailedMountevent. It does not fail for ever: as soon as you create the ConfigMap, the pod starts. - It creates a directory on the node and writes one file per key of the ConfigMap inside it.
- It mounts that directory at
/etc/nginx/conf.dinside the container.
And here is the point that surprises everyone the first time: the mount replaces the contents of the container's directory. Whatever the nginx image brought in /etc/nginx/conf.d (the default default.conf file) stops being visible. It is not deleted from the image; it is simply hidden under the mount point, just as in Linux when you mount a disk over a directory that already had files.
Let us see it:
total 12
drwxrwxrwt 3 root root 120 Aug 5 09:41 .
drwxr-xr-x 3 root root 4096 Aug 5 09:41 ..
drwxr-xr-x 2 root root 80 Aug 5 09:41 ..2026_08_05_09_41_18.1042318874
lrwxrwxrwx 1 root root 32 Aug 5 09:41 ..data -> ..2026_08_05_09_41_18.1042318874
lrwxrwxrwx 1 root root 17 Aug 5 09:41 compression.conf -> ..data/compression.conf
lrwxrwxrwx 1 root root 17 Aug 5 09:41 security.conf -> ..data/security.conf
lrwxrwxrwx 1 root root 11 Aug 5 09:41 spa.conf -> ..data/spa.confThis output contains all the magic of the mechanism, so it is worth reading slowly:
- The files the application sees (
compression.conf, etc.) are symbolic links to..data/. ..datais itself a symbolic link to a timestamped directory.- The real directory contains the actual files.
Why so much indirection? Because it allows an atomic update. When the ConfigMap changes, the kubelet writes a new directory with a new timestamp and then moves the ..data link in one go. The application never sees a half-finished state with half the files old and half new. It is an implementation detail, but it explains two behaviours you will see later on: why the update works, and why it does not work with subPath.
- Selecting specific keys with
items and defaultMode
items and defaultModeBy default all keys are projected. Often that is not what you want: the same ConfigMap can hold keys destined for environment variables (API_URL) and keys that are files (nginx.conf), and mounting them all together would fill /etc/nginx/conf.d with rubbish that nginx would try to interpret.
With items you choose which keys are projected and under which file name:
volumes:
- name: nginx-config
configMap:
name: web-store-config
defaultMode: 0444 # default permissions: read only
items:
- key: nginx.conf # key of the ConfigMap
path: default.conf # name of the resulting file
mode: 0400 # permissions for THIS file only
- key: mime-map.types
path: mime.typesDetails that matter:
pathis relative tomountPath. WithmountPath: /etc/nginx/conf.dandpath: default.conf, the file ends up at/etc/nginx/conf.d/default.conf.pathaccepts subdirectories:path: extra/headers.confcreates the subdirectory. What it does not accept is starting with/or containing...- If you use
items, only what you list is projected. Keys not mentioned disappear from the volume. - If a key listed in
itemsdoes not exist in the ConfigMap, the pod does not start. This is desirable behaviour: it fails early and with a clear message instead of starting nginx with no configuration. defaultModeis written in octal and must carry the leading0(0444). If you write444in decimal, YAML reads it as the number 444, which in octal is0674: absurd permissions. It is a real and frequent mistake.- The
modeof a specificitemtakes precedence overdefaultMode.
A warning about permissions: if the container runs as a non-root user (the usual and recommended thing, as you will see in Security Contexts), a defaultMode: 0400 means "read only for the owner", and the owner of the file is root. The process would not be able to read it. For that case, 0444 is the safe choice.
subPath: what it is for and its two dangers
subPath: what it is for and its two dangersGo back to the problem from section 5: mounting a volume over a directory hides what was already there. What if you only want to add or replace a single file inside a directory that must keep the rest of its contents?
The canonical case in Rutas Norte is /etc/nginx/nginx.conf. That file lives in /etc/nginx/, a directory that also contains mime.types, fastcgi_params, the conf.d directory... If we mount the volume at /etc/nginx/, nginx loses everything else and does not start.
The solution is subPath:
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf # path to a FILE, not to a directory
subPath: nginx.conf # which key of the volume is mounted there
readOnly: trueWith this, only the file /etc/nginx/nginx.conf is replaced; the rest of /etc/nginx/ stays intact.
Everything in its place. Now the two dangers, which you need to know before using it:
Danger 1: with subPath the update does NOT propagate. When you mount with subPath, the kubelet copies the file directly instead of creating the chain of symbolic links from section 5. With no ..data link to move, the file is frozen at the value it had when the pod started. You can edit the ConfigMap a hundred times: the container will keep seeing the original until you recreate it. It is by far the number one cause of "I changed the configuration and nothing happens".
Danger 2: it is easy to get the direction wrong. mountPath must point at the destination file and subPath at the source key. If you swap them, or if mountPath points at an existing directory, you will get a directory where you expected a file and the application will fail with a confusing message.
Without subPath |
With subPath |
|---|---|
mountPath is a directory |
mountPath is a file |
| Hides the previous contents of the directory | Preserves the rest of the directory |
Files as symbolic links to ..data |
File copied directly |
| Updates live | Never updates |
| Preferable whenever possible | Only if there is no alternative |
The practical recommendation: avoid subPath if you can. In the nginx case you have a better alternative, and it is the one we will use: mount at /etc/nginx/conf.d/, the directory the image itself reserves for added configuration, without touching /etc/nginx/nginx.conf. That directory is designed to be replaced wholesale.
- The central case: the
nginx.conf of web-store
nginx.conf of web-storeLet us settle the module 2 debt with everything above. We create the file k8s/base/web-store/configmap-nginx.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: web-store-nginx
namespace: rutas-norte-dev
labels:
app: web-store
app.kubernetes.io/name: web-store
app.kubernetes.io/component: frontend
app.kubernetes.io/part-of: rutas-norte
environment: dev
data:
default.conf: |
server {
listen 80;
server_name www.rutasnorte.example;
root /usr/share/nginx/html;
index index.html;
# Security headers for the ticket sales portal
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Compression of the SPA static assets
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Static assets with a hash in the name are cached for a year
location ~* \.(js|css|woff2|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Essential rule in a SPA: any unknown path returns
# index.html so that the browser router can resolve it
location / {
try_files $uri $uri/ /index.html;
}
# Health endpoint for module 7
location = /healthz {
access_log off;
return 200 "ok\n";
}
}And we modify the web-store Deployment we already had:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-store
namespace: rutas-norte-dev
labels:
app: web-store
app.kubernetes.io/name: web-store
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
replicas: 3
selector:
matchLabels:
app: web-store
environment: dev
template:
metadata:
labels:
app: web-store
app.kubernetes.io/name: web-store
app.kubernetes.io/component: frontend
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
containers:
- name: nginx
image: nginx:1.27.1-alpine
ports:
- name: http
containerPort: 80
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d # whole directory: no subPath
readOnly: true
resources: # we will tune these in 03-04
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
volumes:
- name: nginx-config
configMap:
name: web-store-nginx
defaultMode: 0444We apply and check:
kubectl apply -f k8s/base/web-store/configmap-nginx.yaml
kubectl apply -f k8s/base/web-store/deployment.yaml
kubectl rollout status deploy/web-store -n rutas-norte-devconfigmap/web-store-nginx created
deployment.apps/web-store configured
Waiting for deployment "web-store" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "web-store" rollout to finish: 2 out of 3 new replicas have been updated...
deployment "web-store" successfully rolled outWe verify that nginx is reading our configuration and not the one from the image:
kubectl exec -n rutas-norte-dev deploy/web-store -- cat /etc/nginx/conf.d/default.conf | head -5
kubectl exec -n rutas-norte-dev deploy/web-store -- nginx -T 2>/dev/null | grep -c "try_files"server {
listen 80;
server_name www.rutasnorte.example;
root /usr/share/nginx/html;
index index.html;
1nginx -T dumps the effective configuration after resolving every include. Our try_files showing up confirms that the SPA rule is active. And the definitive proof, from an ephemeral pod:
kubectl run test --rm -it --restart=Never --image=curlimages/curl:8.10.1 \
-n rutas-norte-dev -- curl -si http://web-store/healthzHTTP/1.1 200 OK
Server: nginx/1.27.1
Content-Type: text/plain
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
okThe three security headers are there. The nginx configuration no longer lives in the image: it lives in a YAML file reviewable in a pull request.
- Updating a ConfigMap and propagation to volumes
Now for the payoff. We add one more header without rebuilding the image and without restarting anything.
# We edit the YAML in the repository and apply it
kubectl apply -f k8s/base/web-store/configmap-nginx.yamlWe wait and look inside the container:
kubectl exec -n rutas-norte-dev deploy/web-store -- grep Permissions-Policy /etc/nginx/conf.d/default.confThe file has changed inside a container that has not restarted. The three pods still show RESTARTS 0.
About timings and conditions:
- Propagation is not instantaneous. The kubelet refreshes volumes on its sync cycle, governed by
--sync-frequency(1 minute by default), plus the cache time of its API client (configMapAndSecretChangeDetectionStrategy,Watchby default). In practice count on up to a couple of minutes, not seconds. - It propagates only if the volume does not use
subPath(section 7). - It propagates only if the ConfigMap is not immutable (section 10).
- And, above all: the file changing does not mean the application notices. nginx read its configuration at start-up and holds it in memory.
This last point is fundamental. There are three kinds of application:
| Kind of application | Behaviour when the file changes | What to do |
|---|---|---|
| Re-reads the file on every use | Applies itself | Nothing |
| Reloads on a signal (nginx, HAProxy) | Ignores the change | Send the signal or restart |
| Reads only at start-up (most of them) | Ignores the change | Restart the pods |
For nginx we can send it the reload signal, which does not cut any connection in flight:
For the general case, the clean solution is to force a new rollout:
This replaces the pods with the RollingUpdate strategy from module 2, with no service interruption. There is a more elegant and automatic technique — storing a hash of the ConfigMap in an annotation on the template, so that changing the configuration triggers the rollout by itself — which you will see in Environment Variables, where it is even more necessary.
- Immutable ConfigMaps
Since Kubernetes 1.21, a ConfigMap can be declared immutable:
apiVersion: v1
kind: ConfigMap
metadata:
name: web-store-nginx-v3
namespace: rutas-norte-pro
data:
default.conf: |
server { listen 80; }
immutable: true # from here on, neither data nor binaryData can be touchedWhat you gain:
- Performance and scale. This is the main reason. Each kubelet keeps a watch open against the apiserver for every ConfigMap it mounts. In a large cluster that means thousands of connections looking out for changes. If the object is immutable, the kubelet closes the watch: there is nothing to watch. The load on the apiserver drops noticeably.
- Protection against accidental change. Nobody can modify the production configuration by mistake in one stroke.
- Determinism. A pod that starts today and another that starts a month from now with the same ConfigMap will have exactly the same configuration. With a mutable ConfigMap, two replicas of the same Deployment could hold different contents if the ConfigMap changed between their start-ups.
What you lose: the live update from section 9. And there are two hard rules:
- Immutability cannot be reverted. Once
immutable: true, you cannot go back tofalse. The only option is to delete the object and recreate it. metadatacan still be changed (labels, annotations). What is frozen isdataandbinaryData.
Trying to modify it produces a very clear error:
The ConfigMap "web-store-nginx-v3" is invalid: data: Forbidden: field is immutable when `immutable` is setThe pattern that goes with immutability is versioning the name: web-store-nginx-v3, -v4, and so on. The Deployment points at the specific version, and changing configuration means creating a new ConfigMap and updating the reference in the Deployment, which triggers a rollout with history and rollback. It is exactly what the Kustomize generators do by adding a suffix with the hash of the contents.
Recommendation for Rutas Norte: mutable in dev (iterate fast with nginx -s reload), immutable with a versioned name in pro (determinism and rollback).
- The 1 MiB limit and what to do if you exceed it
A ConfigMap cannot exceed 1 MiB, counting the sum of data and binaryData plus the metadata. The limit is not arbitrary: ConfigMaps are stored in etcd, which imposes a default maximum size of 1.5 MiB per value, and etcd is the heart of the cluster.
When you go over, the error is immediate:
Or else, when the validation is done by the object itself:
Bear in mind as well that base64 inflates by 33%: an 800 KiB .ico takes up about 1.07 MiB in binaryData and does not fit.
What to do if you get close to the limit:
| Situation | Solution |
|---|---|
| Many files that belong together | Split into several ConfigMaps and mount several volumes |
| A single huge file (dataset, catalogue) | It is not configuration. It goes in a persistent volume (module 5) or is downloaded at start-up |
| Large binaries (fonts, images) | They belong in the container image |
| Compressed templates | .tar.gz in binaryData and decompression with an init container (06-04) |
The rule of thumb: if you are approaching a megabyte, you are almost certainly putting something into a ConfigMap that is not configuration. In Rutas Norte this would apply to the full catalogue of routes and stops, which is business data and lives in bookings-postgres, not in a ConfigMap.
- Per-environment ConfigMaps in Rutas Norte
Remember the restriction from section 2: a ConfigMap is namespaced and a pod can only mount those from its own namespace. Since the Rutas Norte environments are namespaces, this fits naturally: one ConfigMap per environment, with the same name in each one.
flowchart TB
subgraph dev["rutas-norte-dev"]
CMD["ConfigMap web-store-config<br/>API_URL: http://bookings-api:8080<br/>LOG_LEVEL: debug"]
DD["Deployment web-store<br/>configMap: web-store-config"]
CMD --- DD
end
subgraph pre["rutas-norte-pre"]
CMP["ConfigMap web-store-config<br/>API_URL: https://api-pre.rutasnorte.example<br/>LOG_LEVEL: info"]
DP["Deployment web-store<br/>configMap: web-store-config"]
CMP --- DP
end
subgraph pro["rutas-norte-pro"]
CMR["ConfigMap web-store-config<br/>API_URL: https://api.rutasnorte.example<br/>LOG_LEVEL: warn"]
DR["Deployment web-store<br/>configMap: web-store-config"]
CMR --- DR
end
IMG["Single image<br/>web-store:2.5.0"] --> DD
IMG --> DP
IMG --> DR
The name of the ConfigMap is identical in all three namespaces. That lets the Deployment be literally the same file in k8s/base/, and the only thing that changes between environments is the ConfigMap in k8s/environments/<environment>/. The repository structure ends up like this:
k8s/
├── base/
│ ├── web-store/
│ │ ├── deployment.yaml # identical for the three environments
│ │ ├── service.yaml
│ │ └── configmap-nginx.yaml # shared nginx.conf
│ └── bookings-api/
│ ├── deployment.yaml
│ └── service.yaml
└── environments/
├── dev/
│ ├── namespace.yaml
│ ├── web-store-config.yaml # dev API_URL
│ └── bookings-api-config.yaml
├── pre/
│ └── ...
└── pro/
└── ...And this is the concrete content of the Rutas Norte configuration per environment. Take this table as a reference; in 03-03 we will extend it to the full catalogue for every component.
| Key | dev |
pre |
pro |
|---|---|---|---|
API_URL |
http://bookings-api:8080 |
https://api-pre.rutasnorte.example |
https://api.rutasnorte.example |
LOG_LEVEL |
debug |
info |
warn |
TIMEOUT_MS |
5000 |
3000 |
2000 |
REDIS_HOST |
redis-cache |
redis-cache |
redis-cache |
REDIS_TTL_SECONDS |
30 |
120 |
300 |
MAX_SEATS_PER_BOOKING |
9 |
9 |
9 |
MAINTENANCE_MODE |
false |
false |
false |
Note two design decisions:
REDIS_TTL_SECONDSgrows as you get closer to production. In development you want a short cache so you can see your changes; in production, with the traffic peaks of bank-holiday weekends and school holidays, you want a long cache that takes load offbookings-postgres.MAINTENANCE_MODEis an operational switch. Setting it totrueand reloading nginx shows the "we are updating" page without deploying anything. It is exactly the kind of lever that justifies separating configuration from code.
And one thing that is not in this table and never should be: the bookings-postgres password. A ConfigMap offers no protection whatsoever: anyone who can list ConfigMaps in the namespace reads its contents in the clear. That debt is settled by the next lesson.
Common Mistakes and Tips
| Mistake | Symptom | Fix |
|---|---|---|
| ConfigMap in another namespace | Pod in ContainerCreating, FailedMount event |
A pod only mounts ConfigMaps from its namespace. Create it in the right one |
| Non-existent ConfigMap | configmap "x" not found in the events |
kubectl get cm -n <ns> and check the name |
| Mounting over a directory with contents | The application cannot find its files | Mount into a purpose-built subdirectory (conf.d) or use subPath |
Expecting updates with subPath |
"I changed the ConfigMap and nothing happens" | subPath does not propagate. Remove it or do a rollout restart |
| Expecting the app to reload by itself | The file changes but the behaviour does not | Send the reload signal or restart the pods |
defaultMode: 644 without the zero |
Strange permissions (--w----r-T) |
It is octal: write 0644 |
0400 with a non-root user |
Permission denied when reading |
Use 0444 or adjust fsGroup |
Using > instead of ` |
` in YAML | The file arrives with its lines glued together |
Confusing --from-file and --from-env-file |
One key with the whole .env inside |
Review the table in section 3.5 |
| Putting secrets in a ConfigMap | Credential readable by anyone | Use Secrets and RBAC |
Objects created with kubectl create |
They do not show up in -l queries |
Generate the YAML with --dry-run=client -o yaml and add labels |
| Exceeding 1 MiB | Too long: must have at most 1048576 bytes |
Split it, or accept that it is not configuration |
Five tips that save hours:
- Label your ConfigMaps with the same scheme as everything else.
kubectl get cm -l app=web-store -Amust work. - Always check from inside the container, not from the YAML:
kubectl exec ... -- cat <path>. It is the only truth. - Document every key with a comment in the YAML. The manifest is the documentation of the configuration.
- One ConfigMap per purpose, not one giant one per application. Keep the one mounted as a file separate from the one feeding environment variables.
- In production, immutable and versioned. Determinism is worth more than convenience.
Exercises
Exercise 1: From the image to the ConfigMap
bookings-api needs a /app/config/fare-rules.json file with the discount rules, which today lives inside its image.
- Create the file
fare-rules.jsonon your machine with this content:
{
"return_discount": 0.12,
"youth_discount": 0.20,
"youth_max_age": 26,
"bank_holiday_surcharge": 0.15
}- Generate with
kubectl(without writing YAML by hand) the manifest of a ConfigMap calledbookings-api-faresinrutas-norte-devfrom that file, and save it underk8s/environments/dev/. - Add the Rutas Norte labelling scheme to it and apply it.
- Write the
volumesandvolumeMountsfragment that mounts it at/app/configin read-only mode. - Verify from inside the pod that the file exists and has the right content.
Exercise 2: subPath and the update trap
- Deploy a
web-storepod that mounts its configuration withsubPathover/etc/nginx/nginx.conf. - Check that
/etc/nginx/still holdsmime.typesand theconf.ddirectory. - Modify the ConfigMap and wait three minutes. Check whether the file has changed inside the container.
- Repeat the experiment mounting the same ConfigMap without
subPathat/etc/nginx/conf.d/. Does it propagate now? - Explain in two lines the difference and what you see with
ls -lain each case.
Exercise 3: Configuration for the three environments
- Create the
web-store-configConfigMap in the three namespaces with the values from the table in section 12. - Show with a single command that three objects exist with that name and that their
LOG_LEVELvalues differ. - Make only the one in
rutas-norte-proimmutable and try to modify it. Copy the error. - Update the production
REDIS_TTL_SECONDSknowing that the ConfigMap is immutable. Describe the complete sequence of steps. - Answer: if tomorrow you have to switch on maintenance mode in production at 3 in the morning, which exact command would you run?
Solutions
Solution 1
mkdir -p k8s/environments/dev
cat > /tmp/fare-rules.json <<'EOF'
{
"return_discount": 0.12,
"youth_discount": 0.20,
"youth_max_age": 26,
"bank_holiday_surcharge": 0.15
}
EOF
kubectl create configmap bookings-api-fares \
--from-file=/tmp/fare-rules.json \
-n rutas-norte-dev \
--dry-run=client -o yaml > k8s/environments/dev/bookings-api-fares.yamlThe generated YAML, with the labels already added by hand:
apiVersion: v1
kind: ConfigMap
metadata:
name: bookings-api-fares
namespace: rutas-norte-dev
labels:
app: bookings-api
app.kubernetes.io/name: bookings-api
app.kubernetes.io/component: backend
app.kubernetes.io/part-of: rutas-norte
environment: dev
data:
fare-rules.json: |
{
"return_discount": 0.12,
"youth_discount": 0.20,
"youth_max_age": 26,
"bank_holiday_surcharge": 0.15
}The Deployment fragment:
volumeMounts:
- name: fares
mountPath: /app/config
readOnly: true
volumes:
- name: fares
configMap:
name: bookings-api-fares
defaultMode: 0444And the verification:
{
"return_discount": 0.12,
"youth_discount": 0.20,
"youth_max_age": 26,
"bank_holiday_surcharge": 0.15
}One note: the ConfigMap key is fare-rules.json because --from-file takes the base name of the file, and the mountPath is the /app/config directory. The result is /app/config/fare-rules.json, the requested path.
Solution 2
With subPath:
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: trueThe directory keeps everything. After modifying the ConfigMap and waiting:
kubectl apply -f configmap-nginx.yaml
sleep 180
kubectl exec -n rutas-norte-dev deploy/web-store -- grep -c "worker_connections 2048" /etc/nginx/nginx.confIt has not changed. The file still holds the value it had at start-up.
Without subPath, mounting at /etc/nginx/conf.d/:
kubectl apply -f configmap-nginx.yaml
sleep 90
kubectl exec -n rutas-norte-dev deploy/web-store -- grep -c "Permissions-Policy" /etc/nginx/conf.d/default.confThis time it does. The difference is visible with ls -la:
# WITHOUT subPath: symbolic links, the update moves ..data atomically
lrwxrwxrwx 1 root root 15 Aug 5 10:41 default.conf -> ..data/default.conf
lrwxrwxrwx 1 root root 32 Aug 5 10:41 ..data -> ..2026_08_05_10_41_02.418822910
# WITH subPath: real file copied at start-up, no link to update
-rw-r--r-- 1 root root 648 Aug 5 09:12 nginx.confWithout subPath, the kubelet updates by writing a new directory and moving the ..data link. With subPath there is no link: the file was copied once and there it stays until the pod is recreated.
Solution 3
for E in dev pre pro; do
kubectl create namespace rutas-norte-$E --dry-run=client -o yaml | kubectl apply -f -
done
kubectl create configmap web-store-config -n rutas-norte-dev \
--from-literal=API_URL=http://bookings-api:8080 \
--from-literal=LOG_LEVEL=debug \
--from-literal=TIMEOUT_MS=5000 \
--from-literal=REDIS_TTL_SECONDS=30
kubectl create configmap web-store-config -n rutas-norte-pre \
--from-literal=API_URL=https://api-pre.rutasnorte.example \
--from-literal=LOG_LEVEL=info \
--from-literal=TIMEOUT_MS=3000 \
--from-literal=REDIS_TTL_SECONDS=120
kubectl create configmap web-store-config -n rutas-norte-pro \
--from-literal=API_URL=https://api.rutasnorte.example \
--from-literal=LOG_LEVEL=warn \
--from-literal=TIMEOUT_MS=2000 \
--from-literal=REDIS_TTL_SECONDS=300The check in a single command:
kubectl get cm web-store-config -A -o custom-columns=\
NS:.metadata.namespace,LOG:.data.LOG_LEVEL,TTL:.data.REDIS_TTL_SECONDS,API:.data.API_URLNS LOG TTL API
rutas-norte-dev debug 30 http://bookings-api:8080
rutas-norte-pre info 120 https://api-pre.rutasnorte.example
rutas-norte-pro warn 300 https://api.rutasnorte.exampleThree objects with the same name and different contents, exactly what allows the Deployment to be identical.
Now for production immutability:
kubectl patch configmap web-store-config -n rutas-norte-pro \
--type merge -p '{"immutable": true}'
kubectl patch configmap web-store-config -n rutas-norte-pro \
--type merge -p '{"data": {"LOG_LEVEL": "info"}}'configmap/web-store-config patched
Error from server: ConfigMap "web-store-config" is invalid: data: Forbidden: field is immutable when `immutable` is setTo update REDIS_TTL_SECONDS in production while it is immutable, the correct sequence is:
- Create a new ConfigMap with a versioned name,
web-store-config-v2, with the new value andimmutable: true. - Apply it.
- Edit the
rutas-norte-proDeployment so that it points atweb-store-config-v2, withkubernetes.io/change-causedocumenting the reason. kubectl rollout statusto follow the rolling update from module 2.- Verify and keep the
-v1for a few days: if you have to runkubectl rollout undo, the Deployment will reference it again and it must exist. - Delete the
-v1once the rollback window has passed.
Note the advantage: production configuration now has history and rollback, just like the code.
For maintenance mode at 3 in the morning, with the mutable nginx ConfigMap:
kubectl patch configmap web-store-nginx -n rutas-norte-pro \
--type merge -p '{"data": {"maintenance.conf": "return 503;"}}'
kubectl exec -n rutas-norte-pro deploy/web-store -- nginx -s reloadNo image rebuild, no deployment, no dropped connections. That is exactly the value of having configuration outside the code.
Conclusion
You have settled the first of the module 2 debts. You know why the third of the twelve factors demands that configuration live outside the code, and you have seen the concrete cost of not doing so: one image per environment, what was tested in pre differing from what was deployed to pro, and a timeout change turned into a full pipeline run. You know the anatomy of the ConfigMap — no spec, no status, data for text and binaryData for everything else, namespaced and with keys as potential file names — and its four creation methods, and you can tell apart the trap between --from-file and --from-env-file.
You have mastered consumption as a volume, which is the most versatile one: the volumes/volumeMounts pair, the fact that the mount hides the previous contents of the directory, the chain of symbolic links towards ..data that makes the update atomic, key selection with items, permissions with defaultMode in octal and how they interact with non-root users, and the two dangers of subPath, starting with the most expensive one: with subPath configuration never propagates. You know that propagation takes up to a couple of minutes and that the file changing is not the same as the application noticing, with the three strategies according to the kind of application. And you know about immutable ConfigMaps, what they gain in apiserver load and determinism, the versioned-name pattern that accompanies them, and the 1 MiB limit and the signal it sends: if you are getting close, it is not configuration.
On the ground, web-store now serves its nginx.conf from a ConfigMap mounted at /etc/nginx/conf.d, with security headers, compression, static-asset caching, the SPA try_files rule and a /healthz endpoint that the probes lesson will make use of. The three environments have their own ConfigMap with the same name, which has allowed the Deployment to be a single file in k8s/base/.
The big debt remains. Everything we have stored today is public: anyone with read permission in the namespace sees the full contents of a ConfigMap. The bookings-postgres password — the database that holds the name, ID number, phone number and email of every Rutas Norte customer — is still in the clear in a Git manifest. The next lesson, Secrets, takes it out of there: you will see how a Secret really differs from a ConfigMap, why base64 is not encryption and you will prove it by decoding one in three seconds, the Secret types and their uses, encryption at rest in etcd which by default is not enabled, how private images are pulled from registry.rutasnorte.example and what real solutions the ecosystem uses when the native Secret is not enough.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
