The two previous lessons built the stores: a ConfigMap with the web-store configuration and a Secret with the bookings-postgres credentials. In both you saw a single way of getting them into the container, the mounted file, and in both the other way was deferred: the environment variable, which is what appears in the vast majority of real manifests and the only thing third-party images such as postgres:16 or redis:7.2-alpine understand. This lesson covers it in full and unifies the previous two: how variables are declared, the four sources they can come from, how a pod can query data about itself with the Downward API, what happens when the same key arrives by two routes, why the shell does not expand what you think it does, and above all the limitation that conditions everything: an environment variable does not change while the process is alive. By the end you will have the complete variable catalogue for the six Rutas Norte components across the three environments.
Contents
- What an environment variable really is inside a container
envwith a literal valueenvFrom: dumping a whole ConfigMap or SecretvalueFrom: taking a specific key- The Downward API:
fieldRef - The Downward API:
resourceFieldRef - Variable expansion with
$(VAR) - Interaction with
commandandargs - Precedence and collisions
- The fundamental limitation: they do not refresh
- The configuration hash in an annotation
- Environment variable versus mounted file
- The Rutas Norte variable catalogue
- What an environment variable really is inside a container
Before the syntax it is worth understanding the mechanism, because it explains almost every surprise.
When the kubelet starts a container, it builds a list of KEY=value pairs and hands it to the runtime (containerd), which in turn passes it to the kernel in the execve() call that launches the main process. From that point on:
- The process stores that list in its own memory (
environ). - Nobody from outside can modify it. There is no system call to change another process's environment. Not Kubernetes, not the kubelet, not
kubectl. - Child processes inherit it at the moment they are created.
flowchart LR
A["Pod manifest<br/>env / envFrom"] --> B["kubelet<br/>resolves ConfigMaps,<br/>Secrets and Downward API"]
B --> C["containerd<br/>KEY=value list"]
C --> D["execve()<br/>main process"]
D --> E["process environ<br/>FROZEN for life"]
E -.->|"inheritance"| F["child processes"]
Everything that follows can be deduced from this: why you have to restart the pod when a ConfigMap changes, why variables are visible in /proc/<pid>/environ, and why the content of a variable is always a text string (there are no numbers or booleans in a process's environment).
Let us check it:
DB_HOST=bookings-postgres
DB_PORT=5432
HOME=/root
HOSTNAME=bookings-api-7f4b8c9d6-2xkqp
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
KUBERNETES_SERVICE_HOST=10.96.0.1
KUBERNETES_SERVICE_PORT=443
LOG_LEVEL=debug
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
REDIS_CACHE_SERVICE_HOST=10.96.184.22
TIMEOUT_MS=5000There are three origins mixed into that list:
- The ones coming from the image (
PATH,HOME): defined by theDockerfileor the base image. - The ones we declared ourselves (
DB_HOST,LOG_LEVEL). - The ones Kubernetes injects automatically:
HOSTNAME(the pod name) and the Service discovery variables (KUBERNETES_SERVICE_HOST,REDIS_CACHE_SERVICE_HOST...).
Those last ones are a relic of the early days of Kubernetes, inherited from Docker links. They have two serious limitations: only the Services that already existed when the pod started appear, and only those in the same namespace. That is why nobody uses them today: discovery is done via DNS, as you will see in Internal DNS. If you have many Services and want a clean environment listing, they can be switched off with enableServiceLinks: false in the pod's spec.
env with a literal value
env with a literal valueThe simplest form. It is a list, not a map, and that is the first detail that confuses people:
spec:
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.5.0
env:
- name: DB_HOST # each entry is an object with name and value
value: bookings-postgres
- name: DB_PORT
value: "5432" # CAREFUL: quotes are mandatory
- name: DEBUG_MODE
value: "false" # booleans tooQuotes on numeric and boolean values are not optional. The value field is of type string in the API schema. If you write value: 5432, the YAML parser produces an integer and validation fails:
error: error validating data: ValidationError(Deployment.spec.template.spec.containers[0].env[1].value):
invalid type for io.k8s.api.core.v1.EnvVar.value: got "number", expected "string"The same problem with value: true, with value: no (YAML 1.1 reads it as a boolean) and with value: 08:00 (it reads it as sexagesimal). Practical rule: quote every env value that is not clearly text.
When to use a literal value instead of a ConfigMap:
Use a literal value |
Use a ConfigMap or a Secret |
|---|---|
| The value is the same across the three environments | The value changes per environment |
It is an internal container path (/etc/secrets/postgres/password) |
It is a URL, a host or a timeout |
It is a structural constant (PGDATA) |
It is something an operator will want to change without touching the Deployment |
| Never, ever, for a credential | Always for a credential (Secret) |
envFrom: dumping a whole ConfigMap or Secret
envFrom: dumping a whole ConfigMap or SecretWhen the ConfigMap has ten keys and you want them all, writing ten valueFrom blocks is absurd. envFrom dumps them in one go:
envFrom:
- configMapRef:
name: bookings-api-config # ALL its keys become variables
- secretRef:
name: bookings-postgres-credentialsWith this ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: bookings-api-config
namespace: rutas-norte-dev
data:
LOG_LEVEL: debug
TIMEOUT_MS: "5000"
MAX_SEATS_PER_BOOKING: "9"The container receives LOG_LEVEL, TIMEOUT_MS and MAX_SEATS_PER_BOOKING with those values. The key name becomes literally the variable name, which imposes a condition: the keys must be valid environment variable names (letters, digits and _, not starting with a digit).
What happens if a key is not valid? Kubernetes ignores it silently and records it as an event:
Events:
Type Reason Age From Message
---- ------ ---- ------- -------
Warning InvalidEnvironmentVariableNames 12s kubelet
Keys [nginx.conf, api-url] from the EnvFrom list in the container "api" are invalidThis is exactly why in the ConfigMaps lesson I insisted on keeping the file ConfigMap separate from the variables ConfigMap. An nginx.conf cannot be an environment variable, but it lives perfectly happily in the same object and ruins the envFrom.
The prefix field
envFrom accepts a prefix, which solves the problem of collisions between sources:
envFrom:
- configMapRef:
name: bookings-api-config
prefix: APP_ # LOG_LEVEL -> APP_LOG_LEVEL
- secretRef:
name: bookings-postgres-credentials
prefix: DB_ # password -> DB_passwordThe result inside the container:
APP_LOG_LEVEL=debug
APP_MAX_SEATS_PER_BOOKING=9
APP_TIMEOUT_MS=5000
DB_database=bookings
DB_password=d3v-Ch4ng3M3-2026
DB_username=rutasnorteNotice DB_password in lower case: the prefix is prepended as it is, without changing the rest. If you want DB_PASSWORD, the Secret key must be called PASSWORD. That is a reasonable motive for naming Secret keys in upper case when you know they will be consumed with envFrom.
optional
By default, if the referenced ConfigMap or Secret does not exist, the pod does not start: it sits in CreateContainerConfigError.
kubectl get pods -n rutas-norte-dev
kubectl describe pod bookings-api-6c8d7f9b5-lmnop -n rutas-norte-dev | grep -A2 "Warning"NAME READY STATUS RESTARTS AGE
bookings-api-6c8d7f9b5-lmnop 0/1 CreateContainerConfigError 0 34s
Warning Failed 5s (x4 over 33s) kubelet
Error: configmap "bookings-api-config" not foundWith optional: true the pod starts without those variables:
envFrom:
- configMapRef:
name: bookings-api-experimental
optional: true # if it does not exist, it is ignoredUse it carefully. It is appropriate for genuinely optional configuration (a feature-flag ConfigMap that only exists in dev), but it is dangerous for anything essential: a silent failure where the pod starts with half its configuration is far harder to diagnose than a pod that does not start at all. In Rutas Norte, optional: true is used only for the experimental flags ConfigMap.
valueFrom: taking a specific key
valueFrom: taking a specific keyWhen you want a single key, or when the variable name must differ from the key name, you use valueFrom:
env:
# From a ConfigMap
- name: LOGGING_LEVEL # name the application expects
valueFrom:
configMapKeyRef:
name: bookings-api-config
key: LOG_LEVEL # key name in the ConfigMap
# From a Secret
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: password
# Optional: if it is missing, the variable simply does not exist
- name: NEW_RELIC_KEY
valueFrom:
secretKeyRef:
name: apm-credentials
key: license
optional: trueThis is the form recommended by default over envFrom, for three solid reasons:
- It is explicit. Reading the Deployment tells you exactly which variables the container receives. With
envFromyou have to go and look at the ConfigMap. - It decouples names. The application expects
LOGGING_LEVELwhile our key is calledLOG_LEVEL, because that is the house convention shared by every component.valueFromtranslates it without renaming anything. - Least privilege. Only the keys the pod needs reach it, just as we did with
itemsin volumes.
envFrom |
valueFrom |
|
|---|---|---|
| Verbosity | Low | High |
| Traceability when reading the YAML | Poor | Excellent |
| Renaming keys | Only with prefix |
Yes, freely |
| Invalid keys | Silently ignored | Explicit error |
| Recommendation | Many keys, names already correct | By default |
And a warning about secretKeyRef: even though the reference is clean, the value ends up in the process environment. It is visible in /proc/<pid>/environ to any process in the container, and many frameworks dump the whole environment onto debug error pages. For high-value credentials, the mounted file from the previous lesson is still the better option.
- The Downward API:
fieldRef
fieldRefSo far configuration has come from outside. The Downward API allows the opposite: letting the container know things about itself and about the pod that holds it, without talking to the Kubernetes API or needing any permissions.
The Rutas Norte case: when a customer reports that a booking failed at 22:47, we want to be able to search the traces for which pod and which node served that request. With 6 replicas of bookings-api spread across 3 nodes, without that information the investigation is impossible.
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.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
- name: SERVICE_ACCOUNT
valueFrom:
fieldRef:
fieldPath: spec.serviceAccountName
- name: ENVIRONMENT
valueFrom:
fieldRef:
fieldPath: metadata.labels['environment'] # a specific labelFields available in fieldRef:
fieldPath |
What it returns | Typical use |
|---|---|---|
metadata.name |
Pod name | Traces, identifying the replica |
metadata.namespace |
Namespace | Composing DNS names, labelling metrics |
metadata.uid |
Pod UID | Correlation in external systems |
metadata.labels['key'] |
Value of a label | Reading environment without duplicating it |
metadata.annotations['key'] |
Value of an annotation | Configuration injected by an operator |
spec.nodeName |
Node it runs on | Diagnosing node problems |
spec.serviceAccountName |
ServiceAccount | Auditing |
status.podIP |
Pod IP | Registering with a discovery service |
status.podIPs |
List of IPs (dual stack) | IPv4/IPv6 networking |
status.hostIP |
Node IP | Sending metrics to a node agent |
Two restrictions you need to know:
- Only those fields are accepted. You cannot ask for
spec.containers[0].imageor an arbitrary pod field. For that you have to talk to the API, which is what we will see in ServiceAccounts. metadata.labelsandmetadata.annotationswithout a specific key only work in adownwardAPIvolume, not inenv. Inenvyou must give the specific key in square brackets.
Let us look at the result and its real value:
POD_NAME=bookings-api-7f4b8c9d6-qh4nc
POD_NAMESPACE=rutas-norte-pro
POD_IP=10.244.2.37
NODE_NAME=rutas-norte-m02
ENVIRONMENT=proAnd this is what a bookings-api trace using those variables looks like:
2026-08-05T22:47:13.412Z INFO [pod=bookings-api-7f4b8c9d6-qh4nc node=rutas-norte-m02 environment=pro]
booking_created id=RN-2026-084412 origin=Bilbao destination=Santander seats=2 ms=184
2026-08-05T22:47:19.887Z ERROR [pod=bookings-api-7f4b8c9d6-qh4nc node=rutas-norte-m02 environment=pro]
booking_failed reason=db_timeout ms=5001With that line, the diagnosis is immediate: every error comes from the same pod and the same node, so the problem is not the application but that particular node (or its network path to bookings-postgres). Without the Downward API, that correlation does not exist.
An important detail about ENVIRONMENT: we could have set it as a literal value: "pro", but then we would have the data duplicated (in the label and in the variable) with the risk of the two drifting apart. Reading it from metadata.labels['environment'] guarantees that it always matches the pod's real label. It is a direct application of the module 2 labelling scheme.
The Downward API as a volume
There is also a file variant, useful when you want all the labels or annotations:
volumes:
- name: pod-info
downwardAPI:
items:
- path: labels
fieldRef:
fieldPath: metadata.labels # no key: all of them
- path: annotations
fieldRef:
fieldPath: metadata.annotationsapp="bookings-api"
app.kubernetes.io/component="backend"
app.kubernetes.io/name="bookings-api"
app.kubernetes.io/part-of="rutas-norte"
environment="pro"
pod-template-hash="7f4b8c9d6"And with one advantage: unlike variables, this file does update if the pod's labels change, using the same mechanism as in the ConfigMaps lesson.
- The Downward API:
resourceFieldRef
resourceFieldRefThe second half of the Downward API exposes the container's declared resources, which we will study in depth in Resource Quotas and Limits:
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
env:
- name: CPU_LIMIT_MILLICORES
valueFrom:
resourceFieldRef:
containerName: api # mandatory if there are several containers
resource: limits.cpu
divisor: 1m # output unit
- name: MEMORY_LIMIT_MB
valueFrom:
resourceFieldRef:
containerName: api
resource: limits.memory
divisor: 1Mi
- name: MEMORY_REQUEST_MB
valueFrom:
resourceFieldRef:
containerName: api
resource: requests.memory
divisor: 1MiResources available: limits.cpu, requests.cpu, limits.memory, requests.memory, limits.ephemeral-storage, requests.ephemeral-storage.
The divisor decides the unit: 1m gives millicores, 1 gives whole cores (rounding up), 1Mi gives mebibytes, 1Gi gibibytes. If you omit it, the default is 1, which for memory means bytes and produces awkward numbers like 536870912.
What is this for in practice? So that the process can size itself. It is a real and very common problem: many runtimes do not see cgroup limits and believe they have all the memory and all the CPUs of the node, so they size their thread pools and their memory heaps generously and end up OOMKilled.
Applied to bookings-api (Node.js):
env:
- name: MEMORY_LIMIT_MB
valueFrom:
resourceFieldRef:
containerName: api
resource: limits.memory
divisor: 1Mi
# The V8 heap gets 75% of the container limit,
# leaving headroom for the rest of the process to avoid the OOMKill
- name: NODE_OPTIONS
value: "--max-old-space-size=384"That 384 is 75% of 512Mi. Ideally it would be calculated, but the $(VAR) expansion of the next section does no arithmetic, so in practice it is computed in the container's entrypoint by reading MEMORY_LIMIT_MB:
# entrypoint.sh for bookings-api
MAX_HEAP=$(( MEMORY_LIMIT_MB * 75 / 100 ))
exec node --max-old-space-size=${MAX_HEAP} server.jsFor the JVM there is an automatic equivalent (-XX:MaxRAMPercentage=75), which is the preferable option when the language offers it.
- Variable expansion with
$(VAR)
$(VAR)Kubernetes allows you to reference some variables from others using the $(NAME) syntax:
env:
- name: DB_HOST
value: bookings-postgres
- name: DB_PORT
value: "5432"
- name: DB_NAME
value: bookings
- name: DB_URL
value: "postgresql://$(DB_HOST):$(DB_PORT)/$(DB_NAME)"The rules, which you need to know precisely:
Rule 1: the syntax is $(VAR), with parentheses. Not ${VAR} and not $VAR. Those two are shell syntax and Kubernetes leaves them untouched.
Rule 2: only variables defined EARLIER in the same env list are expanded. Order matters:
env:
- name: WRONG
value: "$(DEFINED_LATER)" # not expanded: it does not exist yet
- name: DEFINED_LATER
value: "hello"When a reference cannot be resolved, it is left literal. There is no error and no warning, and the value $(DEFINED_LATER) reaches the application exactly like that. It is a source of silent bugs.
Rule 3: variables coming from envFrom are not expanded. Those from envFrom are processed as a block and take no part in the expansion of the env ones. If you need to compose something with a ConfigMap key, bring it in first with valueFrom:
env:
- name: DB_HOST # first we bring it in explicitly
valueFrom:
configMapKeyRef:
name: bookings-api-config
key: DB_HOST
- name: DB_URL # now it can be referenced
value: "postgresql://$(DB_HOST):5432/bookings"Rule 4: variables from the image are not expanded. $(PATH) or $(HOME) are not resolved: Kubernetes only knows the ones it defines itself.
Rule 5: $$ escapes the dollar sign. If your password contains a literal $(, write it $$(.
- Interaction with
command and args
command and argsHere is the most frequent misunderstanding on the topic, and it deserves a section of its own.
- name: worker
image: registry.rutasnorte.example/notifications-worker:1.8.0
env:
- name: MAX_BATCH
value: "50"
command: ["/app/worker"]
args: ["--batch=$(MAX_BATCH)", "--queue=notifications"]This works: Kubernetes expands $(MAX_BATCH) in args with the same rules as the previous section, and the process receives --batch=50.
But this does not work:
And neither does this:
The cause of the confusion: there is no shell. When you write command: ["/app/worker"], containerd runs that binary directly with execve(). /bin/sh is never invoked, so nobody expands $VAR or ${VAR}: that expansion is the shell's job, and the shell is not in the chain.
The three options and when to use each one:
| Form | Who expands it | When to use it |
|---|---|---|
args: ["--batch=$(VAR)"] |
Kubernetes, before starting | Preferred. Simple and shell-free |
command: ["sh","-c","/app/worker --batch=$VAR"] |
The container's shell | If you need pipes, conditionals or arithmetic |
The program itself reads os.environ |
The application | The cleanest of all |
If you use the second form, two important warnings:
Warning 1: the shell becomes PID 1 and many shells do not forward signals to their children. That breaks the graceful termination with SIGTERM you studied in the Pods lesson: when the pod is deleted, the real process never receives the signal and dies abruptly once the grace period runs out. The solution is exec:
With exec, the shell is replaced by the program, which inherits PID 1 and receives the signals.
Warning 2: it is a doorway to command injection. If a variable comes from an untrusted source and you interpret it inside an sh -c, a value like ; rm -rf / gets executed. With Kubernetes' $(VAR) that cannot happen: the value is passed as an argument, it is not interpreted.
And a reminder from the previous lesson: never put a credential in args, not even expanded from a Secret. kubectl describe pod shows Args with the value already resolved.
- Precedence and collisions
When SAME_KEY arrives by several routes, which one wins? The resolution order is strict:
flowchart TB
A["1. IMAGE variables (Dockerfile ENV)"] --> B["2. Kubernetes service variables"]
B --> C["3. envFrom, in list ORDER<br/>(each one overwrites the previous)"]
C --> D["4. env, in list ORDER<br/>(each one overwrites the previous)"]
D --> E["FINAL VALUE<br/>the last one applied wins"]
The rule in one sentence: env always beats envFrom, and within each block the last entry wins.
A full example showing the four levels:
apiVersion: v1
kind: ConfigMap
metadata:
name: config-a
namespace: rutas-norte-dev
data:
LOG_LEVEL: "info"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: config-b
namespace: rutas-norte-dev
data:
LOG_LEVEL: "warn"
---
apiVersion: v1
kind: Pod
metadata:
name: precedence-test
namespace: rutas-norte-dev
spec:
containers:
- name: test
image: busybox:1.36
command: ["sh", "-c", "env | grep LOG_LEVEL; sleep 3600"]
envFrom:
- configMapRef:
name: config-a # info
- configMapRef:
name: config-b # warn <- overwrites config-a
env:
- name: LOG_LEVEL
value: "debug" # <- overwrites everything abovedebug wins, the one from env. And if we removed that env block, warn would win, the one from the last configMapRef.
Tips to save yourself pain here:
- Avoid collisions rather than managing them. One ConfigMap per purpose and no repeated keys.
- Use
prefixwhen you dump several sources withenvFrom. - When in doubt, check the effective value, which is the only truth:
- Watch out for image variables. A base image can define
NODE_ENV=productionin itsDockerfile, and unless you explicitly override it, it will be there without appearing in any manifest of yours.kubectl exec ... -- envreveals it.
- The fundamental limitation: they do not refresh
We come back to the mechanism from section 1. Environment variables are fixed at execve() and nobody can change them afterwards. The consequence is blunt:
If you change a ConfigMap or a Secret, the pods already running will never see the change, no matter how many hours you wait.
Demonstration:
# The current value
kubectl exec -n rutas-norte-dev deploy/bookings-api -- printenv LOGGING_LEVEL
# We change the ConfigMap
kubectl patch configmap bookings-api-config -n rutas-norte-dev \
--type merge -p '{"data":{"LOG_LEVEL":"trace"}}'
# We confirm that the object HAS changed
kubectl get cm bookings-api-config -n rutas-norte-dev -o jsonpath='{.data.LOG_LEVEL}'; echo
# And now, five minutes later, inside the pod
sleep 300
kubectl exec -n rutas-norte-dev deploy/bookings-api -- printenv LOGGING_LEVELThe object says trace and the process still sees debug. And it will stay that way until the end of time.
A comparison with what does refresh:
| Mechanism | Does it refresh when the source changes? |
|---|---|
| ConfigMap mounted as a volume | Yes, within 1-2 minutes |
| Secret mounted as a volume | Yes, within 1-2 minutes |
Volume with subPath |
No |
| Volume of an immutable object | No |
| Downward API in a volume (labels) | Yes |
| Environment variable (any origin) | Never |
The three possible ways out:
- Restart the pods by hand. It works and it is what we did when rotating credentials:
The problem is that you have to remember. If someone changes the ConfigMap in a pull request and forgets the restart, the platform is left in a state where the manifest says one thing and the pods do another. That silent gap is dangerous: it is discovered weeks later, when a pod restarts for some other reason and suddenly changes behaviour without anybody having touched anything.
-
Use a mounted file for whatever has to change live.
-
Automate the restart with a hash, which is the elegant solution and the subject of the next section.
- The configuration hash in an annotation
The technique is simple and has become a standard pattern: store a digest (hash) of the ConfigMap and Secret contents in an annotation on the pod template.
The key is that Kubernetes triggers a new rollout when anything inside spec.template changes, annotations included. Since the hash depends on the configuration contents, changing the configuration changes the hash, changing the hash changes the template, and changing the template triggers the rolling update.
flowchart LR
A["You change the ConfigMap"] --> B["You recompute the hash<br/>sha256 of the contents"]
B --> C["The spec.template<br/>annotation changes"]
C --> D["The Deployment detects<br/>that the template changed"]
D --> E["Automatic RollingUpdate<br/>with no service outage"]
In the manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
replicas: 4
selector:
matchLabels:
app: bookings-api
environment: pro
template:
metadata:
labels:
app: bookings-api
app.kubernetes.io/name: bookings-api
app.kubernetes.io/part-of: rutas-norte
environment: pro
annotations:
# These values are computed by the pipeline before applying
rutasnorte.example/config-hash: "a3f5b81c9d2e4770"
rutasnorte.example/secret-hash: "7e21c40ab6f39185"
spec:
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.5.0
envFrom:
- configMapRef:
name: bookings-api-config
- secretRef:
name: bookings-postgres-credentialsHow the hash is computed in the deployment pipeline:
#!/usr/bin/env bash
set -euo pipefail
NS=rutas-norte-pro
# Hash of the ConfigMap contents (the data field only, not volatile metadata)
CONFIG_HASH=$(kubectl get cm bookings-api-config -n "$NS" -o jsonpath='{.data}' \
| sha256sum | cut -c1-16)
SECRET_HASH=$(kubectl get secret bookings-postgres-credentials -n "$NS" -o jsonpath='{.data}' \
| sha256sum | cut -c1-16)
kubectl patch deployment bookings-api -n "$NS" -p "$(cat <<EOF
{"spec":{"template":{"metadata":{"annotations":{
"rutasnorte.example/config-hash":"${CONFIG_HASH}",
"rutasnorte.example/secret-hash":"${SECRET_HASH}"
}}}}}
EOF
)"
kubectl rollout status deployment/bookings-api -n "$NS" --timeout=5mdeployment.apps/bookings-api patched
Waiting for deployment "bookings-api" rollout to finish: 2 out of 4 new replicas have been updated...
deployment "bookings-api" successfully rolled outIt is important to hash only .data: if you hashed the whole object, resourceVersion would change on every update even when the contents were identical, and you would get spurious rollouts.
Four advantages of this pattern:
- Changing the configuration deploys by itself. There is nothing to remember.
- You get history. It shows up in
kubectl rollout historyand can be undone withrollout undo, just like a code change. - There is no gap. The running pod always corresponds to the current ConfigMap.
- It is auditable. The annotation says which exact version of the configuration each revision corresponds to.
It is worth knowing that the ecosystem tools do this for you:
| Tool | How it solves it |
|---|---|
| Kustomize | configMapGenerator adds a hash suffix to the ConfigMap name; the Deployment references it and changes by itself |
| Helm | The checksum/config annotation with include, which is the canonical pattern in its templates |
| Reloader (Stakater) | A controller that watches ConfigMaps and Secrets and runs rollout restart automatically when they change |
| Argo CD | Detects the drift between Git and the cluster and reconciles it |
For Rutas Norte, while we work with plain YAML, the script above in the pipeline is enough. In module 10 we will replace it with Kustomize.
- Environment variable versus mounted file
You now have all the elements to decide. This is the decision table:
| Criterion | Environment variable | Mounted file |
|---|---|---|
| Short, simple value | Yes | Overkill |
| Complete configuration file | Impractical | Yes |
| Binary content | No | Yes (binaryData) |
| Must refresh without restarting | Cannot | Yes |
| Compatible with third-party images | Almost always | Only if they accept *_FILE |
Visible in /proc/<pid>/environ |
Yes (risk) | No |
| Can leak in an error dump | Yes (risk) | Unlikely |
| Inherited by child processes | Yes (sometimes undesirable) | No |
Visible in kubectl describe pod |
The reference, not the value | The reference |
Appears in docker inspect / crictl inspect |
Yes, with the value | No |
| Start-up cost | None | A volume to prepare |
Can be composed with $(VAR) |
Yes | No |
| Practical size limit | A few KB | 1 MiB |
Decision rules for Rutas Norte:
- Non-sensitive configuration that is stable for the pod's lifetime → environment variable.
LOG_LEVEL,TIMEOUT_MS,REDIS_HOST. It is the simplest thing and everyone understands it. - Configuration files → volume. The
nginx.confofweb-store, thefare-rules.jsonofbookings-api. - High-value credentials → volume, with
*_FILEif the image accepts it. Thebookings-postgrespassword forbookings-api. - Credentials demanded by third-party images → variable with
secretKeyRef, no alternative.POSTGRES_PASSWORDin the official PostgreSQL image. - Configuration that must change live → volume. The nginx
MAINTENANCE_MODE. - Pod identity → Downward API in variables. It is information that is immutable for the pod's lifetime, so the limitation does not get in the way.
- The Rutas Norte variable catalogue
We close with the complete inventory, which is also the state of the platform at the end of this lesson. In each case I mark the origin: L literal, CM ConfigMap, S Secret, DA Downward API.
web-store (nginx serving the SPA)
| Variable | Origin | dev |
pre |
pro |
|---|---|---|---|---|
NGINX_ENTRYPOINT_QUIET_LOGS |
L | 1 |
1 |
1 |
POD_NAME |
DA | metadata.name |
same | same |
web-store barely uses variables: its configuration is the nginx.conf from the mounted ConfigMap, because nginx reads a file, not the environment. It is the perfect example of rule 2.
bookings-api (REST API in Node.js)
| Variable | Origin | dev |
pre |
pro |
|---|---|---|---|---|
NODE_ENV |
L | development |
production |
production |
PORT |
L | 8080 |
8080 |
8080 |
LOGGING_LEVEL |
CM LOG_LEVEL |
debug |
info |
warn |
DB_HOST |
CM | bookings-postgres |
same | same |
DB_PORT |
CM | 5432 |
5432 |
5432 |
DB_NAME |
CM | bookings |
bookings |
bookings |
DB_USER |
S username |
(secret) | (secret) | (secret) |
DB_PASSWORD_FILE |
L | /etc/secrets/postgres/password |
same | same |
DB_POOL_MAX |
CM | 5 |
10 |
25 |
REDIS_HOST |
CM | redis-cache |
same | same |
REDIS_TTL_SECONDS |
CM | 30 |
120 |
300 |
MAX_SEATS_PER_BOOKING |
CM | 9 |
9 |
9 |
TIMEOUT_MS |
CM | 5000 |
3000 |
2000 |
POD_NAME |
DA | metadata.name |
same | same |
NODE_NAME |
DA | spec.nodeName |
same | same |
ENVIRONMENT |
DA | metadata.labels['environment'] |
same | same |
MEMORY_LIMIT_MB |
DA | limits.memory / 1Mi |
same | same |
Look at DB_POOL_MAX: it grows with the environment because production has more replicas and more traffic on bank-holiday weekends and school holidays, but it cannot grow without limit, because PostgreSQL's max_connections is finite and replicas × DB_POOL_MAX must not exceed it. With 4 replicas and DB_POOL_MAX: 25 that is 100 connections. It is exactly the kind of calculation that should be documented in the YAML with a comment.
bookings-postgres
| Variable | Origin | Value |
|---|---|---|
POSTGRES_USER |
S username |
(secret) |
POSTGRES_PASSWORD |
S password |
(secret) |
POSTGRES_DB |
S database |
(secret) |
PGDATA |
L | /var/lib/postgresql/data/pgdata |
Here there is no choice: the official PostgreSQL image reads those variables. This is rule 4. Putting PGDATA in a subdirectory is an essential precaution for when the persistent volume of module 5 arrives: the mount point usually contains a lost+found that prevents the database from initialising.
redis-cache
| Variable | Origin | dev |
pre |
pro |
|---|---|---|---|---|
REDIS_MAXMEMORY |
CM | 64mb |
256mb |
1gb |
REDIS_MAXMEMORY_POLICY |
CM | allkeys-lru |
same | same |
allkeys-lru is a business decision: redis-cache is the seat-availability cache and it is expendable, so we prefer it to discard the least used keys rather than reject writes.
notifications-worker
| Variable | Origin | dev |
pre |
pro |
|---|---|---|---|---|
LOG_LEVEL |
CM | debug |
info |
warn |
DB_HOST |
CM | bookings-postgres |
same | same |
DB_PASSWORD_FILE |
L | /etc/secrets/postgres/password |
same | same |
SMTP_HOST |
CM | mailhog |
smtp-pre.rutasnorte.example |
smtp.rutasnorte.example |
SMTP_PORT |
CM | 1025 |
587 |
587 |
SMTP_USER |
S | (secret) | (secret) | (secret) |
SMTP_PASSWORD |
S | (secret) | (secret) | (secret) |
SENDER |
CM | [email protected] |
no-reply@pre... |
[email protected] |
MAX_BATCH |
CM | 10 |
50 |
200 |
POLL_INTERVAL_S |
CM | 30 |
15 |
5 |
POD_NAME |
DA | metadata.name |
same | same |
In dev the SMTP setting points at mailhog, a local mail catcher: no real emails are sent to customers in development. It is a data-protection decision, not a convenience one, and it is exactly the kind of thing that separating configuration makes possible.
occupancy-reports (arriving in module 6)
| Variable | Origin | Planned value |
|---|---|---|
DB_HOST |
CM | bookings-postgres |
REPORT_DATE |
L | $(date -d yesterday) from the CronJob |
S3_TARGET |
CM | s3://reports-rutasnorte/<environment>/ |
The complete bookings-api Deployment
We bring everything we have learnt together into a single manifest, which is the real state of the component at the end of this lesson:
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app: bookings-api
app.kubernetes.io/name: bookings-api
app.kubernetes.io/component: backend
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
replicas: 4
selector:
matchLabels:
app: bookings-api
environment: pro
template:
metadata:
labels:
app: bookings-api
app.kubernetes.io/name: bookings-api
app.kubernetes.io/component: backend
app.kubernetes.io/part-of: rutas-norte
environment: pro
annotations:
rutasnorte.example/config-hash: "a3f5b81c9d2e4770"
rutasnorte.example/secret-hash: "7e21c40ab6f39185"
spec:
imagePullSecrets:
- name: registry-rutasnorte
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.5.0
ports:
- name: http
containerPort: 8080
env:
# --- Literals: identical across the three environments ---
- name: NODE_ENV
value: "production"
- name: PORT
value: "8080"
- name: DB_PASSWORD_FILE
value: "/etc/secrets/postgres/password"
# --- From the ConfigMap, with renaming ---
- name: LOGGING_LEVEL
valueFrom:
configMapKeyRef:
name: bookings-api-config
key: LOG_LEVEL
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: bookings-api-config
key: DB_HOST
- name: REDIS_TTL_SECONDS
valueFrom:
configMapKeyRef:
name: bookings-api-config
key: REDIS_TTL_SECONDS
# --- From the Secret: only the user; the password goes via file ---
- name: DB_USER
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: username
# --- Downward API: identity for the traces ---
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: ENVIRONMENT
valueFrom:
fieldRef:
fieldPath: metadata.labels['environment']
- name: MEMORY_LIMIT_MB
valueFrom:
resourceFieldRef:
containerName: api
resource: limits.memory
divisor: 1Mi
# --- Composed: DB_HOST is already defined above ---
- name: DB_BASE_URL
value: "postgresql://$(DB_HOST):5432/bookings"
volumeMounts:
- name: db-credentials
mountPath: /etc/secrets/postgres
readOnly: true
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
volumes:
- name: db-credentials
secret:
secretName: bookings-postgres-credentials
defaultMode: 0400
items:
- key: password
path: passwordThis manifest can be published without leaking anything. It contains references, not values. It is exactly the bar we set ourselves at the start of the module.
Common Mistakes and Tips
| Mistake | Symptom | Fix |
|---|---|---|
| Numeric value without quotes | invalid type ... got "number" |
value: "5432" |
value: no without quotes |
You get false |
Always quote |
ConfigMap with invalid keys plus envFrom |
Missing variables, InvalidEnvironmentVariableNames event |
Keep the file ConfigMap separate from the variables one |
| Non-existent ConfigMap or Secret | CreateContainerConfigError |
Create it, or optional: true if it really is optional |
optional: true on the essentials |
Silent start-up with incomplete configuration | Reserve it for the genuinely optional |
${VAR} or $VAR in args |
You get the literal text | Use $(VAR), or sh -c |
$(VAR) referencing something defined later |
You get the literal text, with no warning | Define first, reference afterwards |
$(VAR) on a key from envFrom |
It is not expanded | Bring it in with valueFrom first |
sh -c without exec |
The SIGTERM never reaches the real process |
command: ["sh","-c","exec ..."] |
Credential in args |
Visible in describe pod |
Never. Use secretKeyRef or a volume |
| Expecting a ConfigMap change to arrive on its own | "I changed the ConfigMap and nothing happens" | Variables do not refresh. rollout restart or a hash |
Collision between envFrom and the image |
An unexpected value | kubectl exec -- printenv VAR |
resourceFieldRef without divisor |
Numbers in bytes | divisor: 1Mi |
metadata.labels without a key in env |
Validation error | In env you must write metadata.labels['key'] |
Tips:
valueFromby default,envFromonly when there are many and they are already named correctly. The traceability of the manifest is worth more than saving lines.- Add the Downward API to every component from day one. It costs six lines and on the day of an incident it is worth its weight in gold.
- Document every variable with a comment in the manifest: what it does, what range is valid, who consumes it.
- Order
envin blocks — literals, ConfigMap, Secret, Downward API, composed — as in the final manifest in section 13. It reads far better. - Whenever in doubt,
kubectl exec -- env. The manifest is the intention; the process environment is the reality.
Exercises
Exercise 1: The four sources in a single pod
Create a diagnostic pod called inspector-config in rutas-norte-dev that receives variables by all four routes and prints them:
- A literal
COMPONENT=inspector. - Every key of the
bookings-api-configConfigMap with theCFG_prefix. - Only the
passwordkey of thebookings-postgres-credentialsSecret, under the nameDB_KEY. - The pod name, the node and the
environmentlabel via the Downward API. - A
SUMMARYvariable composed with$(VAR)containing<component>@<node>:<environment>.
Apply the manifest, show the output and explain why point 5 only works if the variables are in the right order.
Exercise 2: Prove that variables do not refresh
- Deploy
bookings-apiwithLOGGING_LEVELcoming from the ConfigMap. - Check the effective value inside the pod.
- Change the ConfigMap to
traceand wait three minutes. Check the value in the pod and the value of the object again. - Also mount the same ConfigMap as a volume at
/etc/configand repeat the experiment. What difference do you observe? - Implement the hash technique: compute the
sha256of the ConfigMap's.data, put it in an annotation on thetemplateand prove that changing the ConfigMap and recomputing the hash triggers a rollout.
Exercise 3: Debug an args that does not expand
A colleague has deployed notifications-worker with this fragment and complains that the worker processes batches of size ${MAX_BATCH} instead of 200:
- name: worker
image: busybox:1.36
env:
- name: MAX_BATCH
valueFrom:
configMapKeyRef:
name: worker-config
key: MAX_BATCH
command: ["sh", "-c", "echo Processing batch of ${MAX_BATCH}; sleep 3600"]
args: ["--queue=$MAX_BATCH"]- Explain what is wrong in
argsand what is right incommand, and why they are different cases. - Fix the manifest using the Kubernetes syntax.
- Explain why this
commandwithsh -cis problematic for the graceful termination from module 2 and fix it. - Rewrite the fragment without using a shell at all.
- If
MAX_BATCHcame from anenvFrominstead of avalueFrom, would the$(MAX_BATCH)expansion work inargs? Justify your answer.
Solutions
Solution 1
apiVersion: v1
kind: Pod
metadata:
name: inspector-config
namespace: rutas-norte-dev
labels:
app: inspector-config
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
restartPolicy: Never
containers:
- name: inspector
image: busybox:1.36
command: ["sh", "-c", "env | sort; echo '---'; echo \"SUMMARY=$SUMMARY\""]
envFrom:
- configMapRef:
name: bookings-api-config
prefix: CFG_
env:
# Order matters for point 5
- name: COMPONENT
value: "inspector"
- name: DB_KEY
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: password
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: ENVIRONMENT
valueFrom:
fieldRef:
fieldPath: metadata.labels['environment']
# Composed: all its references are defined ABOVE
- name: SUMMARY
value: "$(COMPONENT)@$(NODE_NAME):$(ENVIRONMENT)"pod/inspector-config created
CFG_DB_HOST=bookings-postgres
CFG_LOG_LEVEL=debug
CFG_MAX_SEATS_PER_BOOKING=9
CFG_REDIS_TTL_SECONDS=30
CFG_TIMEOUT_MS=5000
COMPONENT=inspector
DB_KEY=d3v-Ch4ng3M3-2026
ENVIRONMENT=dev
HOSTNAME=inspector-config
NODE_NAME=rutas-norte
POD_NAME=inspector-config
SUMMARY=inspector@rutas-norte:dev
---
SUMMARY=inspector@rutas-norte:devPoint 5 works because COMPONENT, NODE_NAME and ENVIRONMENT are defined before SUMMARY in the env list. Kubernetes resolves the list in order and can only substitute what it has already processed. If SUMMARY were in first position, the value would literally be $(COMPONENT)@$(NODE_NAME):$(ENVIRONMENT), with no error and no warning.
And notice the side effect that serves as a warning: DB_KEY with the password appears in the pod's log, because the command runs env. Anyone with kubectl logs can see it, and in production that log would go to the centralised system of module 7. Never dump the environment in a container that consumes secrets.
Solution 2
kubectl exec -n rutas-norte-dev deploy/bookings-api -- printenv LOGGING_LEVEL
kubectl patch cm bookings-api-config -n rutas-norte-dev --type merge -p '{"data":{"LOG_LEVEL":"trace"}}'
sleep 180
kubectl get cm bookings-api-config -n rutas-norte-dev -o jsonpath='{.data.LOG_LEVEL}'; echo
kubectl exec -n rutas-norte-dev deploy/bookings-api -- printenv LOGGING_LEVELThe object says trace, the process still says debug. With the volume added:
volumeMounts:
- name: config
mountPath: /etc/config
readOnly: true
volumes:
- name: config
configMap:
name: bookings-api-configkubectl exec -n rutas-norte-dev deploy/bookings-api -- cat /etc/config/LOG_LEVEL; echo
kubectl exec -n rutas-norte-dev deploy/bookings-api -- printenv LOGGING_LEVELThe same ConfigMap, the same pod, the same instant, two different values. The file refreshed and the variable did not. That is the difference on a single screen.
The hash:
NS=rutas-norte-dev
H=$(kubectl get cm bookings-api-config -n $NS -o jsonpath='{.data}' | sha256sum | cut -c1-16)
echo "hash: $H"
kubectl patch deployment bookings-api -n $NS \
-p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"rutasnorte.example/config-hash\":\"$H\"}}}}}"
kubectl rollout status deployment/bookings-api -n $NS
kubectl exec -n $NS deploy/bookings-api -- printenv LOGGING_LEVELhash: c81f4a9e2b7d0356
deployment.apps/bookings-api patched
Waiting for deployment "bookings-api" rollout to finish: 1 out of 3 new replicas have been updated...
deployment "bookings-api" successfully rolled out
traceThis time it works. And with history:
Solution 3
argsis wrong,commandis right. Inargssomeone wrote$MAX_BATCHwith shell syntax; Kubernetes only understands$(VAR), so it leaves the text as it is and the program receives--queue=$MAX_BATCH. Incommand, by contrast, the${MAX_BATCH}expansion does work, but Kubernetes is not the one doing it: it is done by thesh -cthat is running. They are two different expansions performed by two different actors, and confusing them is the origin of the bug.
There is also a second, hidden problem: when you define command with sh -c "...", the contents of args are added as positional arguments of the shell ($0, $1...), not of the program. That --queue=... never reaches anywhere useful.
- Fixed with the Kubernetes syntax:
- name: worker
image: busybox:1.36
env:
- name: MAX_BATCH
valueFrom:
configMapKeyRef:
name: worker-config
key: MAX_BATCH
command: ["/app/worker"]
args:
- "--batch=$(MAX_BATCH)"
- "--queue=notifications"- The problem with
sh -cwithoutexec: the shell is the container's PID 1 and/app/workeris its child. When Kubernetes deletes the pod it sendsSIGTERMto PID 1, that is, to the shell. Most shells do not forward signals to their children, so the worker never finds out and keeps processing emails until theterminationGracePeriodSecondsexpires (30 s by default) and theSIGKILLarrives, killing it outright. If it was sending a confirmation email, that email is lost.
With exec, the shell replaces itself with the worker, which becomes PID 1 and receives the SIGTERM directly.
- Without a shell at all, which is the right way:
- name: worker
image: registry.rutasnorte.example/notifications-worker:1.8.0
env:
- name: MAX_BATCH
valueFrom:
configMapKeyRef:
name: worker-config
key: MAX_BATCH
command: ["/app/worker"]
args: ["--batch=$(MAX_BATCH)", "--queue=notifications"]With no shell there is no signal problem, no risk of command injection, and the expansion is done by Kubernetes before anything starts. The cleanest thing of all would be for /app/worker to read MAX_BATCH straight from its environment and drop args altogether.
- No, it would not work. The
$(VAR)expansion only reaches variables defined in theenvlist before their use. The ones coming throughenvFromare resolved as a separate block and take no part in the expansion ofenvor ofargs.--queue=$(MAX_BATCH)would arrive literal. The fix is to bring it in explicitly withvalueFrombefore using it, even if it also arrives through theenvFrom.
Conclusion
With this lesson you close the configuration block of the module. You understand the real mechanism — the KEY=value list the kubelet hands to execve() and that stays frozen in the process — and everything else follows from it. You have mastered the four sources: the literal with its mandatory quotes, envFrom with prefix and optional and its trap of invalid keys being silently ignored, valueFrom with configMapKeyRef and secretKeyRef as the recommended option for being explicit and for allowing renaming, and the Downward API in its two forms, fieldRef for pod identity and resourceFieldRef so the process can size itself and not end up OOMKilled.
You know that bookings-api now records in every trace which pod and which node served it, which turns an intractable incident into a two-minute diagnosis. You know the five rules of $(VAR) expansion — parentheses, order, nothing from envFrom, nothing from the image, $$ to escape — and why in command/args there is no shell unless you ask for one, with the consequences that brings: ${VAR} is not expanded, and if you use sh -c without exec you break the graceful termination from module 2. You are clear about precedence (env beats envFrom, and within each block the last one wins) and about how to check the truth, which is always kubectl exec -- printenv.
And above all you have internalised the limitation that governs the design decisions: environment variables never refresh, with the solution of the configuration hash in a template annotation that turns a ConfigMap change into a rolling update with history and rollback. With the "variable versus file" decision table you no longer hesitate over where to put each thing, and the complete catalogue of the six Rutas Norte components per environment is the map of the platform as it stands now: one image per component, zero credentials in Git and all the variability concentrated in k8s/environments/.
Two module 2 debts remain, and both are about resources. No namespace has a quota, so one wrong deployment in rutas-norte-dev — one replica too many, a restart loop, a pod asking for 16 GiB — can consume the cluster capacity and leave rutas-norte-pro with nowhere to run. And the requests and limits we have been writing since module 2 are set by eye, with no criteria at all. The next lesson, Resource Quotas and Limits, tackles it: you will see who uses requests (the scheduler) and who uses limits (the kubelet through cgroups), the crucial difference between CPU, which is throttled, and memory, which kills the process with OOMKilled, what happens when the sum of limits exceeds the cluster capacity, and you will put a ResourceQuota on each of the three Rutas Norte environments so that dev cannot frighten pro ever again.
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
