Process containment: securityContext and Pod Security
Sooner or later, some library in your app will have a security hole. The question is not if an attacker gets into a container, but how much they can do once inside. A container that runs as root, can write everywhere and holds special Linux powers is an open door to the node. A few lines of securityContext close most of those doors.
The idea: a hotel guest, not the manager
A hotel guest gets a key to one room. A manager has a master key, the safe code and access to the boiler room. Your app needs to be a guest: enough rights to do its job, nothing more. This is called the principle of least privilege.
Do not run as root
Many images run as root (user ID 0) unless told otherwise, including the official node image. Root inside a container is not quite root on the node, but it is much closer than you want: one kernel bug or misconfiguration away.
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node # the node image ships a "node" user, UID 1000
CMD ["node", "index.js"]Fix it at the source: the image itself runs as a normal user. Files are owned by that user so the app can still read them.
securityContext:
runAsNonRoot: true # refuse to start if the user would be root
runAsUser: 1000 # run as this user ID…
runAsGroup: 1000 # …and this group IDrunAsNonRoot is a safety check: if the image would run as root, the container fails with CreateContainerConfigError instead of quietly running as root.
Two catches when you switch to non-root:
- Ports below 1024 need extra rights. Listen on 4000 or 8080 instead of 80; the Service can still expose port 80 (chapter 11).
- Mounted volumes may be owned by root.
fsGroup: 1000at Pod level makes Kubernetes give that group access to the volume.
Capabilities: drop the superpowers
Linux splits root's powers into about 40 capabilities, like "change network settings" (NET_ADMIN), "change file owners" (CHOWN) or "bind to port 80" (NET_BIND_SERVICE). Container runtimes give every container a default handful. A web API needs none of them.
securityContext:
capabilities:
drop: ["ALL"] # take every superpower away
add: ["NET_BIND_SERVICE"] # give back only what is truly needed (rarely)
The opposite extreme is privileged: true: all capabilities plus access to the node's devices. That is the manager's master key. Only special system tools (some network or storage drivers) need it. Your apps never do.
A read-only filesystem
If an attacker cannot write files, they cannot easily download tools or change your code. readOnlyRootFilesystem: true makes the container's own filesystem read-only. Most apps still need somewhere to write temp files, so mount a small emptyDir (chapter 30) just there:
spec:
containers:
- name: posts
image: micro-posts:1.0
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp # the only writable place
volumes:
- name: tmp
emptyDir: {}
Privilege escalation and seccomp
allowPrivilegeEscalation: false
Stops a process from gaining more rights than it started with, for example through a "setuid" program. Even if an attacker finds such a file in the image, it will not make them root.
seccompProfile: RuntimeDefault
The kernel offers hundreds of system calls; a web app uses a fraction. A seccomp profile is a filter that blocks rarely needed, risky calls. RuntimeDefault is the container runtime's sensible built-in list.
Pod level vs container level
securityContext can appear in two places. Pod-level settings apply to every container; container-level settings override them for one container. Click the parts:
apiVersion: v1
kind: Pod
metadata:
name: posts
spec:
securityContext: # Pod level: all containers
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: posts
image: micro-posts:1.0
securityContext: # container level: just this one
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
Pod level
Who the processes run as (runAsUser, runAsGroup, runAsNonRoot), the group that owns volumes (fsGroup) and a default seccomp profile. Set it once for every container in the Pod, sidecars included.
Container level
Settings about one process: capabilities, read-only filesystem, privilege escalation, privileged. It can also override the Pod-level user settings for that one container.
Pod Security Standards: rules for the whole namespace
Writing good settings is nice; enforcing them is better. Kubernetes defines three ready-made levels, the Pod Security Standards:
| Level | Allows | For |
|---|---|---|
privileged | Everything | System components (CNI, storage drivers), in their own namespace |
baseline | Most apps, but no obvious holes: no privileged, no host network or host paths, no extra dangerous capabilities | A safe minimum for everything |
restricted | Only hardened Pods: non-root, no privilege escalation, all capabilities dropped, seccomp on | Your own apps, the goal |
The built-in Pod Security Admission checks these when Pods are created. You switch it on with labels on the namespace, in three modes:
apiVersion: v1
kind: Namespace
metadata:
name: blog
labels:
pod-security.kubernetes.io/enforce: baseline # reject Pods that break baseline
pod-security.kubernetes.io/warn: restricted # warn about anything not restricted
pod-security.kubernetes.io/audit: restricted # and record it in the audit log
$ kubectl apply -f posts-deployment.yaml
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false,
unrestricted capabilities, runAsNonRoot != true, seccompProfile not set
deployment.apps/posts-depl created
warn and audit set to restricted, fix what they report, then raise enforce. Note that enforce blocks Pods; a Deployment is still accepted but its ReplicaSet cannot create Pods, so check kubectl get events.Try it: which level does it pass?
Toggle settings and see the YAML and the strictest Pod Security level the container still meets:
The blog app, hardened
Put together, every service in the blog app can look like this, and passes restricted:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile: { type: RuntimeDefault }
containers:
- name: posts
image: micro-posts:1.0 # Dockerfile ends with USER node
ports: [{ containerPort: 4000 }] # above 1024: no capability needed
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts: [{ name: tmp, mountPath: /tmp }]
volumes:
- { name: tmp, emptyDir: {} }
Recap
- Assume the app will be hacked one day; limit what the attacker can do.
- Run as non-root:
USER nodein the Dockerfile,runAsNonRoot: truein Kubernetes. - Drop all capabilities; never use
privilegedfor apps. - Read-only root filesystem, plus an
emptyDirfor/tmp. allowPrivilegeEscalation: falseandseccompProfile: RuntimeDefault.- Enforce with Pod Security Standards via namespace labels: warn/audit
restricted, enforce at leastbaseline.
Quick check
runAsNonRoot: true, the Pod shows CreateContainerConfigError: "container has runAsNonRoot and image will run as root". Fix?Show answer
The image has no non-root USER. Add USER node to the Dockerfile and rebuild, or set runAsUser: 1000 in the securityContext (and make sure that user can read the app files).
readOnlyRootFilesystem: true the app crashes with "EROFS: read-only file system, open '/tmp/upload-123'". What do you add?Show answer
An emptyDir volume mounted at /tmp, so the app has one writable folder while everything else stays read-only.
SecurityContext and Pod Security: common questions
How do I run a Node.js container as non-root in Kubernetes?
Add USER node to the Dockerfile and set runAsNonRoot: true in the securityContext so Kubernetes refuses to start it as root.
What replaced PodSecurityPolicy?
Pod Security Admission, which enforces the privileged, baseline and restricted Pod Security Standards through namespace labels.