We closed module 4 with a platform that was reachable, encrypted and segmented, and with a debt written into a manifest comment back in module 2: bookings-postgres has no persistent storage. This module settles it, and it starts at the beginning, which is not the PersistentVolume but something more basic: understanding why a container's filesystem disappears and what exactly a volume is in Kubernetes. You will see that the volume is a property of the pod, not of the container, and that this distinction solves a container restart but not a pod replacement — precisely the problem the database has been dragging along. In this lesson you will demonstrate the data loss with your own hands, master the volumes/volumeMounts pair that governs every volume type in the rest of the module, and work in depth on the two types that depend on no external infrastructure: emptyDir and hostPath.

Contents

  1. Why a container's filesystem is ephemeral
  2. Demonstration: losing data in bookings-postgres
  3. What a volume is in Kubernetes
  4. The volumes and volumeMounts pair
  5. mountPath, readOnly, subPath and subPathExpr
  6. emptyDir in detail
  7. Sharing an emptyDir between containers of the same pod
  8. hostPath: what it is and why it is dangerous
  9. The configuration volumes: configMap, secret, downwardAPI
  10. The projected volume: everything in one directory
  11. Summary table of volume types
  12. Generic ephemeral volumes: the bridge to the rest of the module

  1. Why a container's filesystem is ephemeral

A container image is made up of read-only layers stacked on top of each other. When the runtime (containerd in your minikube) starts a container from postgres:16.4, it does not copy those layers: it mounts them stacked by means of a union filesystem (overlayfs) and adds a single writable layer on top, empty and exclusive to that container.

flowchart TB
    subgraph CONT["Running container"]
        RW["Writable layer (rw)<br/>empty at start-up<br/>DESTROYED when the container is recreated"]
    end
    subgraph IMG["Image postgres:16.4 (read-only, shared)"]
        L3["Layer 3: PostgreSQL configuration"]
        L2["Layer 2: PostgreSQL binaries"]
        L1["Layer 1: Debian base system"]
    end
    RW --> L3 --> L2 --> L1

Everything the process writes — a new file, a change to an existing one, PostgreSQL's data files under /var/lib/postgresql/data — goes to that writable layer. And that layer has a devastating property: its lifecycle is exactly the container's. When the container is destroyed, the layer is deleted.

It is worth telling apart, with precision, two events that sound alike and are not:

Event What happens to the container What happens to the writable layer
The main process fails and restartPolicy: Always restarts it A new container is created in the same pod Lost (a new, empty layer)
kubectl delete pod or a rollout restart A new pod is created, with new containers Lost
The node goes down and the pod is rescheduled elsewhere New pod on another node Lost
kubectl exec comes and goes Nothing, the container stays alive Preserved

In other words: any restart, of whatever kind, wipes out what was written. And this is not a defect of Kubernetes; it is the very definition of a container and the reason they are lightweight and replaceable. The price is that state has to be taken out of the container explicitly, and that "outside" is the volume.

You will remember from 03-04 that this writable layer consumes the node's ephemeral-storage and that a container filling it up causes the pod to be evicted. Volumes are part of that accounting too, as we will see with emptyDir.

  1. Demonstration: losing data in bookings-postgres

Do it. It is the exercise that gives the whole module its meaning. We start from the current state: bookings-postgres running as a Deployment in rutas-norte-dev, with no volume at all. We create a real booking inside the database and, on top of that, an arbitrary witness file:

POD=$(kubectl get pod -n rutas-norte-dev -l app=bookings-postgres \
        -o jsonpath='{.items[0].metadata.name}')

# 1. A table and a fictitious booking
kubectl exec -n rutas-norte-dev "$POD" -- psql -U rutasnorte -d bookings -c \
  "CREATE TABLE IF NOT EXISTS bookings (
     id serial PRIMARY KEY, customer text, route text, date date);
   INSERT INTO bookings (customer, route, date)
     VALUES ('Marta Iglesias', 'Bilbao-Santander', '2026-08-15');"

# 2. A witness file in the writable layer
kubectl exec -n rutas-norte-dev "$POD" -- \
  sh -c 'echo "persistence test" > /var/lib/postgresql/data/WITNESS.txt'

Now we trigger what in production would be caused by a deployment, an eviction or a node failure:

kubectl delete pod -n rutas-norte-dev "$POD"
kubectl wait --for=condition=ready pod -n rutas-norte-dev \
  -l app=bookings-postgres --timeout=120s

POD=$(kubectl get pod -n rutas-norte-dev -l app=bookings-postgres \
        -o jsonpath='{.items[0].metadata.name}')

kubectl exec -n rutas-norte-dev "$POD" -- psql -U rutasnorte -d bookings -c \
  "SELECT * FROM bookings;"
kubectl exec -n rutas-norte-dev "$POD" -- cat /var/lib/postgresql/data/WITNESS.txt
ERROR:  relation "bookings" does not exist

cat: /var/lib/postgresql/data/WITNESS.txt: No such file or directory
command terminated with exit code 1

Everything is gone. The ReplicaSet did its job perfectly — there is a Running pod, the Service routes, the application starts — and yet the business has lost every booking. This is the exact point that separates a stateless workload from a stateful one: for web-store, recreating the pod is free; for bookings-postgres it is a catastrophe.

  1. What a volume is in Kubernetes

A volume is a directory, whose content and backing vary with its type, that Kubernetes makes available to the containers of a pod.

The formal definition matters less than these two statements, which are the ones to internalise:

  1. The volume is declared on the pod, not on the container. It lives in the pod's spec.volumes. Containers merely mount it at a path, through spec.containers[].volumeMounts. That is why two containers in the same pod can see the same volume at different paths.
  2. The volume's lifecycle is tied to the pod, not to the container. A volume survives a container restart. What happens when the pod disappears depends on the volume type: some die with it (emptyDir) and others do not (those backed by a PersistentVolumeClaim).
flowchart TB
    subgraph POD["Pod"]
        M1["Container A<br/>volumeMounts: data -> /var/lib/data"]
        M2["Container B<br/>volumeMounts: data -> /input (readOnly)"]
        V["volumes:<br/>- name: data<br/>&nbsp;&nbsp;emptyDir: {}"]
    end
    M1 --> V
    M2 --> V

That second statement explains exactly how far this lesson goes. An emptyDir fixes the case "the PostgreSQL container crashed and the kubelet restarted it": the new container mounts the same directory again and finds its data. It does not fix the case "the pod was replaced", which is the one you have just demonstrated. For that you need the PersistentVolumes of 05-02.

  1. The volumes and volumeMounts pair

Every volume requires two declarations linked by name. It is the most repeated pattern in Kubernetes and it does not change whatever the type:

apiVersion: v1
kind: Pod
metadata:
  name: volume-demo
  namespace: rutas-norte-dev
  labels: { app: volume-demo, app.kubernetes.io/part-of: rutas-norte, environment: dev }
spec:
  containers:
    - name: writer
      image: busybox:1.36
      command: ["sh", "-c", "echo hello > /data/greeting.txt && sleep 3600"]
      volumeMounts:                   # (2) WHERE THIS container mounts it
        - name: temp-space            #     must match volumes[].name
          mountPath: /data
  volumes:                            # (1) WHICH volume the POD has
    - name: temp-space
      emptyDir: {}

The two blocks, precisely:

  • spec.volumes (pod level): a list of volumes. Each element has a name that is unique within the pod and exactly one type key (emptyDir, hostPath, configMap, secret, persistentVolumeClaim, projected, ephemeral...). Declaring a volume that no container mounts is legal but useless; for some types (such as configMap) it still means the pod will not start if the referenced object does not exist.
  • spec.containers[].volumeMounts (container level): where that volume is seen inside that particular container. The name is the cross-reference.

If the name does not match, the error is immediate and explicit when the pod is created:

The Pod "volume-demo" is invalid: spec.containers[0].volumeMounts[0].name:
Not found: "temp-spac"

initContainers can mount volumes too, and that is precisely the mechanism that makes the initialisation pattern studied in 06-04 useful: an init container prepares files in a volume and the main container finds them ready.

  1. mountPath, readOnly, subPath and subPathExpr

The volumeMounts fields deserve detail because each one solves a real problem.

Field What it does Watch out
mountPath Absolute path inside the container where the volume appears Hides whatever the image had at that path
readOnly Mounts the volume read-only Defaults to false; set it to true whenever you can
subPath Mounts a subdirectory or a file of the volume, not its root Does not receive ConfigMap/Secret updates
subPathExpr Same as subPath but with environment variables expanded Mutually exclusive with subPath
mountPropagation Propagation of submounts to or from the host Only for very special cases

mountPath hides what was there

This is the effect that surprises people most at first. If you mount a volume at /etc/nginx/conf.d in the web-store image, everything the image shipped in that directory stops being visible, just as when you mount a disk over a directory in Linux. It is not deleted: it is covered up. That is why mounting over /etc or /usr breaks the container.

subPath: mounting a single file

You already used it in 03-01 to inject a single configuration file without covering the whole directory. The same technique works for data volumes:

      volumeMounts:
        - name: postgres-data
          mountPath: /var/lib/postgresql/data
          subPath: pgdata          # mounts <volume>/pgdata, not the root

In PostgreSQL's case there is a specific operational reason to do this, explained in detail in 05-03: many formatted block volumes come with a lost+found directory at their root, and PostgreSQL refuses to initialise a data directory that is not empty. In Rutas Norte we already solved it back in module 3 through the equivalent route of the PGDATA variable, and in 05-03 we will bring both approaches together.

subPathExpr: one path per pod

subPathExpr expands the container's environment variables, including those from the Downward API of 03-03. The canonical case is having each replica write into its own subdirectory of a shared volume:

        - name: worker
          image: registry.rutasnorte.example/notifications-worker:1.8.0
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          volumeMounts:
            - name: logs
              mountPath: /var/log/notifications
              subPathExpr: $(POD_NAME)       # /logs/notifications-worker-abc123

Each notifications-worker pod writes its logs into a subdirectory named after itself, and they never tread on each other.

  1. emptyDir in detail

emptyDir is the simplest volume type and by far the most used.

When it is created: at the moment the pod is assigned to a node. It always starts empty, hence the name.

When it is deleted: when the pod is removed from the node, definitively and irrecoverably. Let it be crystal clear what survives it and what does not:

Event Does the emptyDir survive?
A container in the pod fails and restarts Yes
kubectl exec kills a secondary process Yes
The pod is deleted (kubectl delete pod, rollout, eviction) No
The node reboots No (the pod is recreated)

Where it lives: by default, on the node's disk, under /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~empty-dir/. You can see it in minikube:

minikube ssh -p rutas-norte
sudo ls /var/lib/kubelet/pods/*/volumes/kubernetes.io~empty-dir/

sizeLimit

With no limit, an emptyDir can fill the node's disk and cause DiskPressure, with the eviction of unrelated pods that you studied in 03-04. It is capped with sizeLimit:

  volumes:
    - name: attachments
      emptyDir:
        sizeLimit: 500Mi     # the kubelet evicts the pod if it goes over

When the limit is exceeded, the kubelet evicts the pod (it does not return a write error to the process). The event says so bluntly:

Warning  Evicted  pod/notifications-worker-7c9d5-k4m2n
  Usage of EmptyDir volume "attachments" exceeds the limit "500Mi".

medium: Memory

With medium: Memory, the volume is backed by a tmpfs: RAM, not disk. It is blazingly fast and the content never touches a disk, which makes it ideal for sensitive material — it is exactly what Kubernetes does under the bonnet with secret volumes, as you saw in 03-02. It is declared with emptyDir: { medium: Memory, sizeLimit: 64Mi }.

The important warning: whatever you write there counts against the memory limit of the container using it. If web-store has limits.memory: 256Mi and you fill 200 MiB of tmpfs, the process is left with 56 MiB and will end up OOMKilled without any "process memory" graph explaining it. Rule: if you use medium: Memory, add it to the memory limit and always set sizeLimit. Without sizeLimit, the tmpfs can grow to half the node's RAM.

  1. Sharing an emptyDir between containers of the same pod

This is the use case that on its own justifies the existence of emptyDir, and it fits what you learned in 02-01: the containers of a pod share the network and can share volumes, but not their filesystem.

In Rutas Norte, web-store renders HTML templates of timetables and routes. We add a secondary container that generates them every five minutes from the API and leaves them in a directory; nginx simply serves them. Both see the same emptyDir, and nginx mounts it read-only.

# k8s/base/web-store-deployment.yaml (fragment: the podTemplate)
    spec:
      containers:
        # --- Main container: serves the HTML ---
        - name: nginx
          image: nginx:1.27.1
          ports: [{ name: http, containerPort: 8080 }]
          volumeMounts:
            - name: template-cache
              mountPath: /usr/share/nginx/html/timetables
              readOnly: true            # nginx must NOT write here
          resources:
            requests: { cpu: 50m, memory: 128Mi }
            limits:   { cpu: 200m, memory: 192Mi }   # 128Mi process + 64Mi tmpfs

        # --- Secondary container: regenerates the templates ---
        - name: template-generator
          image: registry.rutasnorte.example/template-generator:1.2.0
          env:
            - name: API_URL
              value: "http://bookings-api:8080"      # the Service from module 2
            - name: INTERVAL_SECONDS
              value: "300"
          volumeMounts:
            - name: template-cache
              mountPath: /output            # ANOTHER path, SAME volume
          resources:
            requests: { cpu: 25m, memory: 64Mi }
            limits:   { cpu: 100m, memory: 128Mi }

      volumes:
        - name: template-cache
          # RAM: regenerated every 5 min, no disk needed
          emptyDir: { medium: Memory, sizeLimit: 64Mi }

The design points to read out of that manifest:

  • One single volume, two different mountPath values. The generator writes to /output; nginx reads from /usr/share/nginx/html/timetables. It is the same directory on the node.
  • readOnly: true on the consumer. If tomorrow a vulnerability allows writing through nginx, the attacker cannot alter the templates it serves. It costs one line.
  • medium: Memory with sizeLimit: 64Mi, and nginx's limits.memory raised to 192Mi to absorb it. Without that adjustment, a spike in templates would kill the container with OOMKilled.
  • This is not persistence. If the pod dies, the cache is lost and the generator rebuilds it on the next start-up. That is exactly the criterion for choosing emptyDir: rebuildable data.

Verification, including proof that readOnly works:

kubectl exec -n rutas-norte-dev deploy/web-store -c template-generator -- \
  sh -c 'echo "<h1>Bilbao-Santander</h1>" > /output/test.html'
kubectl exec -n rutas-norte-dev deploy/web-store -c nginx -- \
  cat /usr/share/nginx/html/timetables/test.html
kubectl exec -n rutas-norte-dev deploy/web-store -c nginx -- \
  sh -c 'echo intruder > /usr/share/nginx/html/timetables/intruder.html'
<h1>Bilbao-Santander</h1>

sh: can't create /usr/share/nginx/html/timetables/intruder.html: Read-only file system
command terminated with exit code 1

  1. hostPath: what it is and why it is dangerous

hostPath mounts a file or directory from the node's filesystem inside the pod.

  volumes:
    - name: node-logs
      hostPath:
        path: /var/log
        type: Directory

The type field validates what is expected to be found at that path, and it is the difference between a clear failure and silent, unexpected behaviour:

type Behaviour
(empty) No check at all. Backwards compatibility; avoid it
Directory A directory must exist; otherwise the pod does not start
DirectoryOrCreate If it does not exist, the kubelet creates it (permissions 0755, owned by the kubelet)
File A file must exist
FileOrCreate If it does not exist, it is created empty
Socket A UNIX socket must exist (typical case: /var/run/docker.sock)
CharDevice / BlockDevice Character or block device

Legitimate uses, all of them infrastructure components, not applications:

  • A log collection agent that reads /var/log/pods on every node (the DaemonSet pattern of 06-02 and of the EFK stack of 07-05).
  • A node metrics exporter that reads /proc and /sys.
  • A CSI plugin that needs access to the kubelet's mount directory.

Why it is a serious risk in production

hostPath breaks the isolation between the pod and the node. The consequences are of the highest severity:

  1. Escalation to root on the node. A pod mounting / or /etc with write access can add an SSH key to /root/.ssh/authorized_keys, or drop a manifest into /etc/kubernetes/manifests/ that the kubelet will start as a static pod with full privileges. That is total control of the node.
  2. Access to other pods' secrets. Under /var/lib/kubelet/pods/ are mounted the volumes of every pod on that node, including the Secrets with the credentials of bookings-postgres.
  3. Runtime escape. Mounting /var/run/containerd/containerd.sock (or the Docker socket) allows privileged containers to be created without going through the Kubernetes API or RBAC.
  4. Data that does not travel. Even if there were no security risk, a hostPath is tied to one specific node: if the pod is rescheduled elsewhere, it finds a different (or empty) directory and the data "vanishes" with no error at all.

That is why hostPath is not a persistence solution for bookings-postgres, even though at first glance it looks like one. And that is why the Pod Security Standards at their baseline level forbid hostPath outright; you will see it enforced in 08-03, and the accompanying container hardening in 08-02.

Rutas Norte rule: no application manifest uses hostPath. If one shows up in a code review, it is rejected without discussion. The legitimate need — real persistence — is covered with a PVC.

  1. The configuration volumes: configMap, secret, downwardAPI

You have already used all three in module 3; we put them back here because they are volumes, with the same volumes/volumeMounts mechanics, and it is worth seeing them as one family.

Type Source of the content Backing Updates by itself
configMap Keys of a ConfigMap Node's disk Yes (except with subPath or immutable: true)
secret Keys of a Secret tmpfs (RAM) Yes (except with subPath or immutable: true)
downwardAPI Fields and resources of the pod itself Node's disk Only labels and annotations
  volumes:
    - name: config
      configMap:
        name: bookings-api-config
        defaultMode: 0444
        items:                      # only these keys, with a chosen name
          - key: application.yaml
            path: app.yaml
    - name: credentials
      secret:
        secretName: bookings-postgres-credentials
        defaultMode: 0400           # read-only for the owner

Two reminders that are often forgotten:

  • Automatic content updates do not restart the process. That is why in 03-03 we added an annotation with the configuration hash to the pod, to force a rollout when it changes.
  • A mount with subPath is frozen: it receives the content as of the moment of mounting and is never updated. It is the trade-off for being able to mount a single file.

  1. The projected volume: everything in one directory

projected combines several sources — configMap, secret, downwardAPI and serviceAccountToken — into a single directory. It serves to avoid filling the container with mount points and, above all, to request ServiceAccount tokens with a controlled audience and expiry, as you saw in 03-06.

  volumes:
    - name: identity-and-config
      projected:
        defaultMode: 0400
        sources:
          - secret:
              name: bookings-postgres-credentials
              items:
                - key: password
                  path: db/password        # -> /etc/rutasnorte/db/password
          - configMap:
              name: bookings-api-config
              items:
                - key: application.yaml
                  path: app/application.yaml
          - serviceAccountToken:
              path: token
              expirationSeconds: 3600
              audience: bookings-api

Mounted at /etc/rutasnorte, the container sees a single tree: app/application.yaml, db/password and token.

Restrictions to remember: inside a projected there is no room for persistentVolumeClaim or emptyDir (only the four sources listed), and the same path cannot be repeated in two sources: the pod is rejected for conflict.

  1. Summary table of volume types

The types in use today on a 1.30+ cluster, with their lifecycle and their use case. The "in-tree" types of specific providers (awsElasticBlockStore, gcePersistentDisk, azureDisk...) are removed or migrated to CSI; do not write them in new manifests.

Type Lifecycle Backing Typical use case in Rutas Norte
emptyDir Dies with the pod Node's disk web-store template cache, temporary files
emptyDir + medium: Memory Dies with the pod RAM (tmpfs) Temporary sensitive data, small fast cache
configMap Dies with the pod Node's disk application.yaml of bookings-api
secret Dies with the pod RAM (tmpfs) bookings-postgres-credentials
downwardAPI Dies with the pod Node's disk Pod name and environment for the logs
projected Dies with the pod Mixed ServiceAccount token with an audience
hostPath Survives the pod, tied to the node Node's disk Infrastructure only (agents, DaemonSets)
local Survives, tied to the node, via a PV Node's disk High-performance local storage (05-02)
persistentVolumeClaim Survives the pod Whatever the PV provides The data of bookings-postgres (05-03)
ephemeral (generic) Dies with the pod Whatever the StorageClass provides Large, disposable working space
csi (inline) Depends on the driver CSI driver Injection of external secrets (Vault, cloud managers)
nfs Survives the pod NFS server Sharing files between several pods

  1. Generic ephemeral volumes: the bridge to the rest of the module

One type remains that acts as a hinge between this lesson and the next ones: the generic ephemeral volume. It is a volume that is provisioned like a PVC — with all the power of real storage: whatever class you want, whatever size you want, expansion, snapshots — but whose lifecycle is the pod's: when the pod is deleted, the automatically created PVC is deleted with it.

  volumes:
    - name: reports-space
      ephemeral:
        volumeClaimTemplate:
          metadata:
            labels: { app: occupancy-reports, environment: dev }
          spec:
            accessModes: ["ReadWriteOnce"]
            storageClassName: standard
            resources: { requests: { storage: 20Gi } }

This is what to use when you need a lot of temporary space — more than reasonably fits on the node's disk — without wanting to keep it: generating a bulky report, batch processing, a large download. In Rutas Norte it will fit occupancy-reports when it arrives in 06-03.

The comparison closes the lesson's mental map:

emptyDir generic ephemeral persistentVolumeClaim
Lifecycle Pod Pod Independent of the pod
Guaranteed size No (cap only) Yes Yes
Storage class Not applicable Yes Yes
Snapshots and expansion No Yes Yes
Survives delete pod No No Yes

And with that, what is missing is laid out: for bookings-postgres we need the third column. That column is built by the five remaining lessons of the module.

Common Mistakes and Tips

Mistake Symptom Fix
Believing emptyDir gives persistence Data lost after a rollout emptyDir dies with the pod; use a PVC (05-03)
Different name in volumes and volumeMounts spec.containers[0].volumeMounts[0].name: Not found Check the cross-reference; they are two blocks linked by name
Mounting over a directory from the image The container starts but "files are missing" mountPath hides whatever was there; use subPath for a single file
medium: Memory without adjusting limits.memory Inexplicable OOMKilled The tmpfs counts against the container's limit (03-04)
emptyDir without sizeLimit Node in DiskPressure, unrelated pods evicted Always set sizeLimit
Expecting a ConfigMap subPath to update The configuration changes and the file does not subPath freezes the content; mount the directory or force a rollout
Using hostPath for application data "Vanished" data when the pod is rescheduled, plus a security hole Forbidden in applications; use a PVC
hostPath with an empty type An unexpected directory is created, or the wrong thing is mounted Always specify type
Mounting read-write what is only read Unnecessary attack surface readOnly: true by default on consumers

Four tips that save hours:

  1. Before writing a volume, answer this: what happens if this is lost? If the answer is "it gets regenerated", emptyDir. If it is "we lose business data", a PVC. There is no middle ground.
  2. Inspect the effective volumes of a running pod, which are not always the ones you thought, with kubectl exec ... -- df -h and kubectl exec ... -- mount | grep -E "tmpfs|overlay".
  3. kubectl describe pod shows the Volumes: and Mounts: sections for each container: it is the quickest place to check that the mount is the one you intended and whether it is ro or rw.
  4. kubectl explain pod.spec.volumes lists every type available in your cluster version, which is the source of truth against any out-of-date documentation.

Exercises

Exercise 1: demonstrate and cap ephemerality

In rutas-norte-dev, create a pod ephemeral-test with the image busybox:1.36 and two volumes: a normal emptyDir mounted at /survives-restart and another emptyDir with medium: Memory and sizeLimit: 32Mi mounted at /in-ram. Write a file in each one. Then:

  1. Kill the container's main process to force a restart and check what survives.
  2. Delete the pod, recreate it and check what survives.
  3. Use mount to verify which of the two is on tmpfs.
  4. Try to write 50 MiB into /in-ram and describe what happens.

Exercise 2: shared cache for web-store

Write k8s/base/web-store-cache.yaml: a web-store Deployment in rutas-norte-dev with two containers sharing an emptyDir called template-cache:

  • nginx (image nginx:1.27.1) mounting it at /usr/share/nginx/html/timetables read-only.
  • template-generator (use busybox:1.36 with a loop that writes the time to an HTML file every 30 seconds) mounting it at /output read-write.

Labels and resources following the course conventions. Show that nginx sees what the generator writes and that it cannot write itself.

Exercise 3: code review

A colleague submits this manifest to give persistence to bookings-postgres in the pre-production cluster. Find four distinct problems and explain the consequence of each.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookings-postgres
  namespace: rutas-norte-pre
spec:
  replicas: 2
  selector:
    matchLabels: { app: bookings-postgres, environment: pre }
  template:
    metadata:
      labels: { app: bookings-postgres, environment: pre }
    spec:
      containers:
        - name: postgres
          image: postgres:16.4
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql
      volumes:
        - name: data
          hostPath:
            path: /data/postgres

Solutions

Exercise 1

# ephemeral-test.yaml
apiVersion: v1
kind: Pod
metadata:
  name: ephemeral-test
  namespace: rutas-norte-dev
  labels:
    app: ephemeral-test
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  containers:
    - name: box
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]
      volumeMounts:
        - name: on-disk
          mountPath: /survives-restart
        - name: in-ram
          mountPath: /in-ram
      resources:
        requests: { cpu: 10m, memory: 32Mi }
        limits:   { cpu: 50m, memory: 96Mi }   # 64Mi process + 32Mi of tmpfs
  volumes:
    - name: on-disk
      emptyDir:
        sizeLimit: 64Mi
    - name: in-ram
      emptyDir:
        medium: Memory
        sizeLimit: 32Mi
kubectl apply -f ephemeral-test.yaml
kubectl wait --for=condition=ready pod/ephemeral-test -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev ephemeral-test -- sh -c \
  'echo disk > /survives-restart/f.txt; echo ram > /in-ram/f.txt'

# 3. What is on tmpfs
kubectl exec -n rutas-norte-dev ephemeral-test -- mount | grep -E "/in-ram|/survives"

# 1. CONTAINER restart: the volume survives
kubectl exec -n rutas-norte-dev ephemeral-test -- kill 1
kubectl wait --for=condition=ready pod/ephemeral-test -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev ephemeral-test -- cat /survives-restart/f.txt /in-ram/f.txt
tmpfs on /in-ram type tmpfs (rw,relatime,size=32768k)
/dev/vda1 on /survives-restart type ext4 (rw,relatime)

disk
ram

Both survive: the volume is tied to the pod, not to the container. Even the RAM one, because the tmpfs belongs to the pod.

# 2. POD deletion: everything is lost
kubectl delete pod ephemeral-test -n rutas-norte-dev
kubectl apply -f ephemeral-test.yaml
kubectl wait --for=condition=ready pod/ephemeral-test -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev ephemeral-test -- ls /survives-restart /in-ram

# 4. Exceeding the tmpfs sizeLimit
kubectl exec -n rutas-norte-dev ephemeral-test -- \
  dd if=/dev/zero of=/in-ram/large bs=1M count=50
/in-ram:
/survives-restart:

dd: writing '/in-ram/large': No space left on device
32+0 records out

Both directories are empty: it is exactly what happens to bookings-postgres today. And with medium: Memory the sizeLimit is the real size of the tmpfs, so the failure is immediate and inside the container. On a disk-backed emptyDir the behaviour is different: the write succeeds and it is the kubelet that, on detecting the excess, evicts the pod with the event Usage of EmptyDir volume ... exceeds the limit. Check it with kubectl get events -n rutas-norte-dev --sort-by=.lastTimestamp.

Exercise 2

# k8s/base/web-store-cache.yaml (fragment: the podTemplate)
    spec:
      containers:
        - name: nginx
          image: nginx:1.27.1
          ports: [{ name: http, containerPort: 80 }]
          volumeMounts:
            - name: template-cache
              mountPath: /usr/share/nginx/html/timetables
              readOnly: true                       # the consumer does NOT write
          resources:
            requests: { cpu: 50m, memory: 96Mi }
            limits:   { cpu: 200m, memory: 160Mi }   # 128Mi + 32Mi of tmpfs
        - name: template-generator
          image: busybox:1.36
          command: ["sh", "-c", "while true; do
              echo \"<h1>Timetables</h1><p>$(date)</p>\" > /output/timetables.html;
              sleep 30; done"]
          volumeMounts:
            - name: template-cache
              mountPath: /output                   # ANOTHER path, SAME volume
          resources:
            requests: { cpu: 10m, memory: 32Mi }
            limits:   { cpu: 50m, memory: 64Mi }
      volumes:
        - name: template-cache
          emptyDir: { medium: Memory, sizeLimit: 32Mi }
kubectl apply -f k8s/base/web-store-cache.yaml
kubectl rollout status deploy/web-store -n rutas-norte-dev

# nginx sees what the generator writes, and serves it over HTTP
kubectl exec -n rutas-norte-dev deploy/web-store -c nginx -- \
  curl -s localhost/timetables/timetables.html

# but it cannot write
kubectl exec -n rutas-norte-dev deploy/web-store -c nginx -- \
  sh -c 'touch /usr/share/nginx/html/timetables/x' || echo "DENIED (correct)"
<h1>Rutas Norte Timetables</h1><p>Wed Aug  5 09:14:22 UTC 2026</p>
touch: cannot touch '...': Read-only file system
DENIED (correct)

Exercise 3

The four problems:

# Problem Consequence
1 hostPath for application data The data is tied to one node: if the pod is rescheduled, the database turns up empty with no error whatsoever. It is also a security hole and is forbidden by the baseline level of the Pod Security Standards (08-03)
2 replicas: 2 Two PostgreSQL processes writing over the same directory: data cluster corruption. PostgreSQL protects itself with a lock file, but only within the same node; on different nodes with shared storage, the corruption is real. A database like this goes to replicas: 1 and strategy: Recreate (or to a StatefulSet, 06-01)
3 mountPath: /var/lib/postgresql instead of /var/lib/postgresql/data It mounts one level too high and hides the content the image ships in that directory (the postgres user's home, with its configuration). The symptom is a start-up that fails or an initdb that is redone
4 Missing resources, strategy: Recreate, the app.kubernetes.io/part-of label and the hostPath type Without resources the pod is Burstable or BestEffort and can be evicted, when the decision from 03-05 is that it should be Guaranteed; with the default strategy (RollingUpdate) the new pod coexists with the old one over the same data; and without type the hostPath validates nothing

The fix is not to touch up this manifest but to change mechanism: a PersistentVolumeClaim, which is what the following lessons build.

Conclusion

You have demonstrated with your own hands the problem that gives the module its name: you created a booking in bookings-postgres, deleted the pod and the booking disappeared. The cause is not a bug: it is the overlayfs writable layer, which is born empty with each container and dies with it. From there comes the rule that governs everything else: in Kubernetes, state has to be taken out of the container explicitly.

That "outside" is the volume, and you can now read it precisely. It is declared on the pod (spec.volumes), not on the container; containers only mount it (volumeMounts), and both blocks are linked by the name. You have mastered the mount fields: mountPath, which hides whatever was at that path; readOnly, which you should set by default on every consumer; subPath, to mount a single file at the price of freezing its content; and subPathExpr, to give each replica its own subdirectory.

You know in depth the two types that depend on no external infrastructure. emptyDir is born empty when the pod is assigned to a node and dies with the pod: it survives a container restart but not its replacement; it is capped with sizeLimit so as not to cause DiskPressure, and with medium: Memory it lives in RAM — fast and never touching disk, but counting against the container's memory limit. You have put it to work in the case it belongs to: the template cache shared by nginx and the generator inside the web-store pod, with the consumer read-only and the data rebuildable. And hostPath, with its type values and its clear verdict: legitimate only for infrastructure, forbidden in applications, because it breaks isolation with the node — root escalation, access to other pods' secrets, escape through the runtime socket — and because it ties the data to one specific machine. You have also put back in place the configuration volumes you were already using — configMap, secret, downwardAPI — and the projected one that gathers them into a single directory, and you have the table of every type with its lifecycle.

What you have not solved is precisely what opened the lesson: none of the volumes seen survives the replacement of the pod. We left the hinge marked with the generic ephemeral volume (ephemeral.volumeClaimTemplate), which is already provisioned as real storage even though it still dies with the pod. One last thread remains to be cut: decoupling the life of the disk from the life of the pod. That calls for a new object, with its own existence in the cluster, administered by whoever manages the infrastructure and consumed by whoever deploys the application. It is the PersistentVolume, and it is the next lesson: Persistent Volumes.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved