Chapter 42 of 47 · Security

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.

Layers of containmentThe app runs inside a container, which runs on the node. securityContext settings act as walls: non-root user, dropped capabilities, read-only filesystem and seccomp. An attacker who breaks the app still hits these walls. node (the host) container walls (securityContext) posts appuser 1000, not root no rootno capabilities read-only filesseccomp filter
Each setting is one more wall between a hacked app and the node.

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.

Two catches when you switch to non-root:

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: {}
Bonus: a read-only container is also immutable. What runs is exactly what was in the image, which makes debugging and auditing easier.

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.

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:

LevelAllowsFor
privilegedEverythingSystem components (CNI, storage drivers), in their own namespace
baselineMost apps, but no obvious holes: no privileged, no host network or host paths, no extra dangerous capabilitiesA safe minimum for everything
restrictedOnly hardened Pods: non-root, no privilege escalation, all capabilities dropped, seccomp onYour 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
Roll it out gently: start with 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.

You may read about PodSecurityPolicy in old tutorials. It was removed from Kubernetes years ago; Pod Security Admission replaced it. For custom rules beyond the three levels, teams add policy engines such as Kyverno or OPA Gatekeeper.

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

Quick check

After adding 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).

With 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.