Skip to content
Documentation Background

Volumes & Storage

Every container has its own isolated filesystem provided by its image. When a container crashes and Kubernetes recreates it, the new instance starts with a completely clean slate — anything written to the container’s local filesystem is gone. For stateful workloads this is a problem. Volumes solve it by providing storage that lives outside the container’s isolated layer.

Container filesystem (no volume)Kubernetes volume
IsolationFully isolated — other containers cannot access itShareable — multiple containers in the same pod can mount it
On container crash/restartAll data lost; container starts with a clean slateData preserved; remounted into the new container instance
On pod deletionDeletedDeleted (unless backed by external PV)
Use casesShort-term scratch, stateless operationsData persistence, inter-container communication, decoupled config

How Volumes Work in Kubernetes

Volumes are not independent Kubernetes objects — they are sub-components of a pod, defined inside the pod spec. This means:

  • Created before containers start: all volumes in a pod are provisioned during pod setup, before any container launches.
  • Outlive container restarts: if a container crashes and is recreated, the volumes remain intact and are remounted into the new container instance.
  • Deleted with the pod: when the entire pod is removed, its volumes are deleted too (unless backed by external persistent storage whose lifecycle is independent).
  • Shared across containers in the same pod: multiple containers can mount the same volume, enabling file-based inter-container communication.
Volumes and Storage in Kubernetes

Mounting is the act of attaching a volume’s filesystem to a specific path inside the container’s directory tree. After mounting, any file read or written at that path goes to the volume — not the container’s ephemeral layer.


TypeLifecycleWritableShared across pods?Use case
emptyDirPodScratch space, inter-container sharing
hostPathNode (external)Node-local onlySystem pods, node-level log access
nfsExternal (NFS server)Shared persistent access across pods
configMapPod (data from ConfigMap)Config files, runtime flags
secretPod (data from Secret)Credentials, certs, keys
downwardAPIPod (runtime metadata)Pod name, namespace, resource limits
projectedPod (aggregated sources)Combine CM + Secret + Downward API
imagePod (OCI image)Seed static assets/DB scripts without init containers
persistentVolumeClaimExternal (PV lifecycle)Depends on access modeDatabases, durable application state
ephemeralPod (inline PVC)PVC-backed temp storage auto-deleted with pod
csiExternalDriver-dependentDirect CSI plugin use without PVC layer

emptyDir is the simplest volume type — it creates an empty directory on the worker node just before the pod’s containers start. It persists data across container restarts within the same pod, but is permanently deleted when the pod is removed.

  • Crash-resistant scratch space — database working directories (e.g., MongoDB /data/db) survive container crashes without external storage
  • Writable space for read-only root filesystems — when a container’s root FS is locked down, emptyDir provides a writable path
  • Inter-container file sharing — a writer container produces files, a reader container serves them, both mount the same volume
spec:
volumes:
- name: cache-vol
emptyDir:
medium: Memory # omit for disk; "Memory" for tmpfs (RAM-backed)
sizeLimit: 128Mi # critical when medium: Memory — prevents OOM
containers:
- name: app
image: my-app:1.0
volumeMounts:
- name: cache-vol
mountPath: /var/cache/app
readOnly: false
emptyDir fieldDefaultEffect
medium"" (node disk)Set to Memory for tmpfs — RAM-backed, never written to disk
sizeLimitUnlimitedCaps storage use; essential for Memory volumes
FieldDescription
mountPathAbsolute path inside the container where the volume appears
readOnlyRestricts write access for this container only (default false)
recursiveReadOnlyEnforces read-only recursively across all subdirectories (set "Enabled") — default is non-recursive
subPathMount a specific subdirectory — isolates per-container access within a shared volume
subPathExprLike subPath but resolves name from an env var
mountPropagationHow host mounts nested below propagate: None / HostToContainer / Bidirectional

emptyDir is just a normal directory on the worker node:

/var/lib/kubelet/pods/<pod-UID>/volumes/kubernetes.io~empty-dir/<volume-name>/

File I/O speed depends on the node’s underlying disk hardware (unless medium: Memory is used).

Because emptyDir starts empty, applications that need pre-seeded files use one of two approaches:

spec:
volumes:
- name: initdb
emptyDir: {}
- name: db-data
emptyDir: {}
initContainers:
- name: seeder
image: my-app/quiz-initdb:0.1
volumeMounts:
- name: initdb
mountPath: /initdb.d/
containers:
- name: mongo
image: mongo:7
volumeMounts:
- name: db-data
mountPath: /data/db
- name: initdb
mountPath: /docker-entrypoint-initdb.d/
readOnly: true

The seeder init container copies scripts into initdb, then exits. MongoDB starts and finds the scripts in its init directory, executing them on first boot.

Method B — Inline Heredoc in Init Container

Section titled “Method B — Inline Heredoc in Init Container”

For small config files, write content directly from the pod manifest:

initContainers:
- name: config-writer
image: busybox
command:
- sh
- -c
- |
cat <<EOF > /mnt/config/app.conf
db_host=mysql-service
log_level=info
EOF
volumeMounts:
- name: config-vol
mountPath: /mnt/config
Multi-Container Sharing
spec:
volumes:
- name: shared-data
emptyDir: {}
containers:
- name: writer # writes files
image: busybox:1.37.0
volumeMounts:
- name: shared-data
mountPath: /var/local/output
- name: nginx # reads and serves files
image: nginx:alpine
volumeMounts:
- name: shared-data
mountPath: /usr/share/nginx/html
readOnly: true # nginx cannot write — security isolation

Marking a mount readOnly is per-container — one container can write while another reads the same volume:

volumeMounts:
- name: shared-data
mountPath: /usr/share/nginx/html
readOnly: true # this container cannot write
# recursiveReadOnly: Enabled # also lock down all subdirectories
FieldBehaviour
readOnly: trueWrite operations from this container are blocked at the volume mount level
recursiveReadOnly: "Enabled"Also enforces read-only recursively on all subdirectory mounts beneath the path

Attempting to write to a read-only mount returns: Read-only file system

Use kubectl exec with the -c flag to target a specific container in a multi-container pod:

Terminal window
# Enter the writer container
kubectl exec business-app -it -c writer -- /bin/sh
# Navigate to the mount path and create a file
cd /var/local/output
touch example.txt
ls
# example.txt
# Exit, then enter the reader container
kubectl exec business-app -it -c nginx -- /bin/sh
# The file created by the writer container should be visible here
ls /usr/share/nginx/html
# example.txt ← data sharing confirmed

hostPath mounts a specific file or directory from the worker node’s physical filesystem directly into a container. The data is not portable — if the pod reschedules to a different node, it sees that node’s filesystem, not the original one’s.

  • System-level pods that need to read node logs, hardware devices, or node-agent sockets
  • Tools that introspect the container runtime (e.g., monitoring agents)

Common exploitation vectors:

  • Mount host / → container has full node root filesystem access
  • Mount /var/run/docker.sock → container can run arbitrary commands on the host with root privileges
spec:
volumes:
- name: host-root
hostPath:
path: /var/log/pods # host filesystem path
type: Directory # optional validation type
containers:
- name: log-agent
image: fluentd:latest
volumeMounts:
- name: host-root
mountPath: /host-logs

The type field tells Kubernetes to validate the path before mounting. If the check fails, the pod will not start.

TypeBehaviour
"" (default)No validation — mount whatever is at the path
DirectoryFails if path is not an existing directory
DirectoryOrCreateUses directory if it exists; creates it (mode 0755) otherwise
FileFails if path is not an existing file
FileOrCreateUses file if it exists; creates it (mode 0644) otherwise
BlockDevicePath must be a block device
CharDevicePath must be a character device
SocketPath must be a UNIX domain socket

To target a specific node in a multi-node cluster, pin the pod with spec.nodeName.


While env vars are fine for short string values, multi-line files (nginx configs, TLS settings, app config files) should be projected as mounted files via a configMap volume. Each key in the ConfigMap becomes a file inside the mounted directory.

For creating ConfigMaps and injecting them as environment variables, see ConfigMaps & Secrets → Injecting ConfigMap Data.

spec:
volumes:
- name: app-config
configMap:
name: app-config # all keys projected as files
optional: true # pod starts even if ConfigMap is missing
containers:
- name: app
image: my-app:1.0
volumeMounts:
- name: app-config
mountPath: /etc/app

Each key in app-config appears as a file under /etc/app/.

volumes:
- name: proxy-config
configMap:
name: proxy-ssl-config
items:
- key: proxy.yaml # only this key is projected
path: proxy.yaml # filename inside the volume

Unlisted keys are omitted entirely from the mounted directory.

Unlike env vars (which are static after container start), files from a ConfigMap volume automatically update when the ConfigMap changes — typically within ~1 minute.

Kubernetes ensures the application never reads a half-written file by using atomic double-symlink updates:

  1. Projected files are symbolic links pointing into a hidden ..data/ directory
  2. ..data/ itself is a symlink to a timestamped snapshot directory
  3. When the ConfigMap changes, Kubernetes writes new content into a fresh timestamped directory
  4. Then atomically swaps the ..data/ symlink to point to the new snapshot — the switch is instantaneous
/etc/app/
proxy.yaml → ..data/proxy.yaml
..data/ → ..2024_08_15_12_00_00.123456789/
proxy.yaml (old content)
After update:
..data/ → ..2024_08_15_12_01_00.987654321/ ← atomic swap
proxy.yaml (new content)

Setting immutable: true on a ConfigMap or Secret completely disables the kubelet’s update watch. Volume-mounted files from an immutable source are frozen permanently — the kubelet stops polling for changes.

apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-v2 # must rename to update — treat like an image tag
immutable: true # kubelet stops watching; no more volume updates
data:
config.yaml: |
feature_x: enabled

Consequences for volume mounts:

  • Files are never updated after initial mount, even if you kubectl edit the CM (the API server rejects edits once immutable: true is set)
  • To roll out a config change, create a new CM with a new name, update the Pod spec to reference the new name, and redeploy
  • Performance benefit: reduces kube-apiserver load at scale — thousands of pods watching for CM updates generate significant traffic. Immutable CMs remove that polling entirely.

Secret volumes work identically to ConfigMap volumes — each key becomes a file — with two important differences:

For creating Secrets, Secret types, and injecting them as environment variables, see ConfigMaps & Secrets → Injecting Secrets.

BehaviourSecret volumeConfigMap volume
Volume spec fieldsecret: + secretName:configMap: + name:
Storage on nodetmpfs (RAM only, never written to disk)Node disk
Base64 decodingAuto-decoded at mount time — app reads plain textNot applicable
spec:
volumes:
- name: tls-creds
secret:
secretName: app-tls # note: secretName, not name
containers:
- name: app
image: my-app:1.0
volumeMounts:
- name: tls-creds
mountPath: /etc/tls
readOnly: true

By default, files in ConfigMap/Secret volumes are created with mode 0644. Override at volume level or per-file:

volumes:
- name: tls-creds
secret:
secretName: app-tls
defaultMode: 0640 # applies to all files unless overridden
items:
- key: tls.key
path: server.key
mode: 0600 # private key: read-only for owner only
- key: tls.crt
path:
server.crt
# inherits defaultMode: 0640

Granting Non-Root Processes Access (fsGroup)

Section titled “Granting Non-Root Processes Access (fsGroup)”

Volume files are owned by root:root by default. If a container runs as a non-root user, it can’t read files with restricted permissions.

Set securityContext.fsGroup at the pod level to change the group ownership of volume files:

spec:
securityContext:
fsGroup: 101 # changes group ownership of ALL volume files to GID 101
containers:
- name: proxy
image: my-proxy:1.0
securityContext:
runAsUser: 101 # proxy user UID

fsGroupChangePolicy — Avoid Slow Startups on Large Volumes

Section titled “fsGroupChangePolicy — Avoid Slow Startups on Large Volumes”

By default (Always), Kubernetes recursively chowns every file in a mounted volume on each pod startup to apply the fsGroup. On volumes with thousands of files (e.g., a large database), this can take minutes, causing pod startup timeouts in production.

Set fsGroupChangePolicy: OnRootMismatch to skip the recursive chown if the root directory already has the correct group ownership:

spec:
securityContext:
fsGroup: 101
fsGroupChangePolicy: OnRootMismatch # Only chown if root dir permissions are wrong
containers:
- name: app
image: my-app:1.0
PolicyBehaviourWhen to use
Always (default)Recursively chowns all volume files on every pod startFirst-run or when you cannot guarantee prior ownership
OnRootMismatchSkips recursive chown if root dir already has correct groupRecommended for large volumes (databases, ML model stores)

The Downward API can project pod metadata as files — useful when the metadata is too large for an env var (labels, annotations) or when a file-reading sidecar needs it.

For injecting Downward API data as environment variables and the full supported fields reference, see ConfigMaps & Secrets → Downward API.

spec:
volumes:
- name: pod-info
downwardAPI:
items:
- path: name.txt
fieldRef:
fieldPath: metadata.name # resolves to the pod's name
- path: namespace.txt
fieldRef:
fieldPath: metadata.namespace
- path: mem-limit.txt
resourceFieldRef:
containerName: app # required for resourceFieldRef in volumes
resource: limits.memory
divisor: 1Mi # express in MiB
containers:
- name: app
image: my-app:1.0
volumeMounts:
- name: pod-info
mountPath: /etc/pod-info

All labels and annotations can only be projected as files (not env vars), making the Downward API volume the only way to expose them:

- path: labels
fieldRef:
fieldPath: metadata.labels # all labels as key="value" lines
- path: annotations
fieldRef:
fieldPath: metadata.annotations # all annotations

A projected volume aggregates multiple sources (ConfigMaps, Secrets, Downward API, ServiceAccount tokens) into a single directory. This solves a key limitation: normally mounting two separate volumes into the same directory hides the first volume’s contents.

subPath lets you insert individual files from different volumes into the same directory, but it disables atomic updates. A projected volume mounts the entire directory as one unit, so all aggregated files update atomically when their sources change.

spec:
volumes:
- name: etc-proxy
projected:
sources:
# Source A: Proxy config from a ConfigMap
- configMap:
name: proxy-ssl-config
items:
- key: proxy.yaml
path: proxy.yaml
# Source B: TLS credentials from a Secret
- secret:
name: app-tls
items:
- key: tls.crt
path: certs/example-com.crt
- key: tls.key
path: certs/example-com.key
mode: 0600 # private key: strict permissions
containers:
- name: proxy
image: my-proxy:1.0
volumeMounts:
- name: etc-proxy
mountPath: /etc/proxy
readOnly: true

The resulting /etc/proxy directory contains files from both sources:

/etc/proxy/
proxy.yaml ← from ConfigMap
certs/
example-com.crt ← from Secret
example-com.key ← from Secret (mode 0600)

Supported sources types: configMap, secret, downwardAPI, serviceAccountToken.

Kubernetes automatically injects a projected volume named kube-api-access-<random> into almost every pod. It contains:

FileSourcePurpose
tokenserviceAccountTokenAuthenticates the pod to the Kubernetes API
ca.crtconfigMap (cluster CA)Verifies the API server’s TLS certificate
namespacedownwardAPIThe pod’s active namespace

If your pod doesn’t need to call the Kubernetes API, disable this bundle:

spec:
automountServiceAccountToken: false # principle of least privilege

An image volume mounts the filesystem of an OCI container image directly into a pod as a read-only volume — no init container or copy script needed.

The traditional approach to seed static assets (DB scripts, LLM weights, static files) was:

  1. Create an init container image bundling the assets
  2. Mount an emptyDir
  3. Copy files from init container → emptyDir
  4. Main container reads from emptyDir

image volumes skip steps 1–3 entirely.

  • Read-only — OCI image filesystems are static; write access is blocked
  • Pull before containers start — the volume image is pulled first, before any container images
  • Feature gate required — needs ImageVolume feature gate enabled in the cluster
spec:
volumes:
- name: initdb
image:
reference: my-app/quiz-questions:latest # OCI image containing the files
pullPolicy: Always
- name: db-data
emptyDir: {} # separate writable volume for MongoDB data
containers:
- name: mongo
image: mongo:7
volumeMounts:
- name: db-data
mountPath: /data/db
- name: initdb
mountPath: /docker-entrypoint-initdb.d/
readOnly: true # image volumes are always read-only

Verifying volume contents:

Terminal window
kubectl describe pod quiz # shows image pull order: volume image pulled first
kubectl exec quiz -c mongo -- ls /docker-entrypoint-initdb.d/

For durable external storage (databases, shared uploads), reference a PersistentVolumeClaim from the pod:

spec:
volumes:
- name: db-storage
persistentVolumeClaim:
claimName: postgres-pvc # must exist in the same namespace
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: db-storage
mountPath: /var/lib/postgresql/data

The PVC decouples the pod from the physical storage — it can bind to block, file, or NFS backends transparently. The data survives pod deletion and rescheduling.

See the Persistent Volumes page for StorageClasses, PV/PVC lifecycle, access modes, and dynamic provisioning.


An ephemeral volume behaves like a PVC-backed volume but is defined inline in the pod spec — no separate PVC object needed. It is deleted automatically when the pod is removed.

spec:
volumes:
- name: scratch
ephemeral:
volumeClaimTemplate:
spec:
accessModes: [ReadWriteOnce]
storageClassName: fast-ssd
resources:
requests:
storage: 5Gi

Use this when you need CSI-backed storage with temporary lifecycle — e.g., large scratch space for a batch job.


Allows a pod to reference a CSI driver directly without a PVC layer. Only supported by certain drivers; most production setups prefer persistentVolumeClaim or ephemeral for better portability.

These in-tree types are deprecated and should no longer be used. Use CSI drivers via persistentVolumeClaim instead.

Deprecated typeModern equivalent
awsElasticBlockStoreEBS CSI driver + PVC
gcePersistentDiskGCP PD CSI driver + PVC
azureDisk / azureFileAzure CSI drivers + PVC
vsphereVolumevSphere CSI driver + PVC
nfsNFS CSI driver + PVC

Terminal window
# List volumes for a running pod
kubectl describe pod <pod-name> | grep -A 20 "Volumes:"
# Check what's mounted inside a container
kubectl exec <pod> -c <container> -- df -h
kubectl exec <pod> -c <container> -- ls -lL /etc/secrets/
# Verify a ConfigMap volume update has propagated
kubectl exec <pod> -- cat /etc/config/app.conf
# Debug pod stuck in ContainerCreating (missing volume source)
kubectl describe pod <pod-name> # check Events section
kubectl get configmap <name> # verify the CM/Secret exists
Volume typespec.volumes fieldReferencesData lifecycle
emptyDiremptyDir: {}NothingDeleted with pod
hostPathhostPath.pathNode filesystemPersists on node
configMapconfigMap.nameConfigMap objectDeleted with pod
secretsecret.secretNameSecret objectDeleted with pod
downwardAPIdownwardAPI.itemsPod/container metadataDeleted with pod
projectedprojected.sourcesMultiple objectsDeleted with pod
imageimage.referenceOCI imageDeleted with pod
persistentVolumeClaimpersistentVolumeClaim.claimNamePVC → PVGoverned by PV reclaim policy
ephemeralephemeral.volumeClaimTemplateAuto-created PVCDeleted with pod