PodSecurityContext vs Container SecurityContext: Every Field, Explained

Quick answer
Kubernetes has two securityContext blocks with the same name, different schemas, and one precedence rule everyone half-remembers. Here is exactly which fields live at each level, what happens when both are set, and the fsGroup and runAsNonRoot traps that break pods in production.
- Two types, one YAML key
- Which fields live at which level
- Precedence: container wins, field by field
- fsGroup: the field that touches your data
- seccompProfile: set it at the pod level
13 min read · Kubernetes
Kubernetes has two fields called securityContext, they have different schemas, and when both set the same field the container-level value wins. That one sentence resolves most of the confusion — but the details underneath it are where pods actually break: fields that exist at only one level, fsGroup silently doing nothing on some volume types, and runAsNonRoot rejecting containers at start time because the image disagrees with the manifest.
The naming does not help. In the API, the pod-level block is a PodSecurityContext and the container-level block is a SecurityContext — two distinct types that happen to share a YAML key. They overlap on some fields, each has fields the other lacks, and the API server will reject a manifest that puts a container-only field at pod level. Knowing which field lives where is not trivia; it is the difference between a manifest that applies and one that fails validation, and between a hardened pod and one you only think is hardened.
Two types, one YAML key
The pod-level securityContext sits under spec and sets defaults for every container in the pod — app containers, init containers, and ephemeral debug containers alike — plus a handful of settings that only make sense pod-wide, like volume ownership. The container-level securityContext sits under each entry in spec.containers (and initContainers) and configures that one container, including everything that maps to per-process Linux security primitives.
1apiVersion: v1
2kind: Pod
3spec:
4 securityContext: # PodSecurityContext — pod-wide defaults + volume settings
5 runAsUser: 1000
6 fsGroup: 2000
7 containers:
8 - name: app
9 securityContext: # SecurityContext — this container only
10 allowPrivilegeEscalation: false
11 capabilities:
12 drop: ["ALL"]The mental model that holds up: the pod level is for identity and volume ownership shared across containers; the container level is for kernel-facing process restrictions. Capabilities, privilege escalation, read-only root filesystems — those are properties of a single process, so they only exist per container. Volume group ownership affects a filesystem mounted into potentially many containers, so it only exists at the pod level.
Which fields live at which level
This is the table to bookmark. "Both" means the pod-level value acts as the default and any container-level value overrides it for that container.
| Field | Pod level | Container level | Notes |
|---|---|---|---|
runAsUser | ✅ | ✅ | Container wins on overlap |
runAsGroup | ✅ | ✅ | Container wins on overlap |
runAsNonRoot | ✅ | ✅ | Validated by kubelet at container start |
seccompProfile | ✅ | ✅ | Container wins on overlap |
seLinuxOptions | ✅ | ✅ | Container wins on overlap |
appArmorProfile | ✅ | ✅ | Fields added in 1.30, GA in 1.31; container wins |
windowsOptions | ✅ | ✅ | Windows nodes only |
fsGroup | ✅ | ❌ | Volume ownership — pod-wide by nature |
fsGroupChangePolicy | ✅ | ❌ | Always (default) or OnRootMismatch |
supplementalGroups | ✅ | ❌ | Extra GIDs for the first process |
sysctls | ✅ | ❌ | Kernel params are namespace-wide |
capabilities | ❌ | ✅ | add / drop lists |
privileged | ❌ | ✅ | Full host access — almost never needed |
allowPrivilegeEscalation | ❌ | ✅ | Forced true when privileged or CAP_SYS_ADMIN |
readOnlyRootFilesystem | ❌ | ✅ | Root FS only — volumes stay writable |
procMount | ❌ | ✅ | Niche; nested-container use cases |
Two consequences of this layout catch people constantly.
First, you cannot drop capabilities pod-wide. There is no capabilities at pod level, so a pod with an app container, two sidecars, and an init container needs drop: ["ALL"] written four times. Miss the init container — which often runs chown or migrations and is the one you least want unrestricted — and your hardening has a hole exactly where the most privileged code runs.
Second, putting a container-only field at pod level is a validation error, not a silent ignore. kubectl apply fails with unknown field "spec.securityContext.allowPrivilegeEscalation" (with server-side validation; client-side strictness varies by version). Annoying, but far better than the reverse — a field that silently did nothing would be a security incident waiting for an audit to find it.
Precedence: container wins, field by field
When the same field appears at both levels, the container value overrides the pod value for that container — and the override is per field, not per block. Setting one field in a container's securityContext does not discard the other pod-level defaults; each field resolves independently.
1spec:
2 securityContext:
3 runAsUser: 1000
4 runAsGroup: 3000
5 fsGroup: 2000
6 containers:
7 - name: app # runs as 1000:3000 — inherits both pod defaults
8 image: registry.example.com/app:1.4.2
9 - name: metrics
10 image: registry.example.com/exporter:2.0.1
11 securityContext:
12 runAsUser: 65532 # runs as 65532:3000 — runAsGroup still inheritedThe metrics container overrides runAsUser but keeps the pod's runAsGroup. This granularity is exactly what you want for the standard pattern: a permissive-enough pod default, tightened or adjusted per container where a sidecar image ships with a different non-root UID baked in.
One asymmetry worth internalising: fsGroup has no container-level equivalent, so no container can opt out of it. If pod-level volume ownership is wrong for one container, your options are restructuring volumes or an init container that fixes permissions — there is no per-container override.
fsGroup: the field that touches your data
fsGroup is the most misunderstood field in either block because it does not configure the process — it configures the volumes. When a pod with fsGroup: 2000 starts, the kubelet recursively changes group ownership of supported volumes to GID 2000, makes them group-writable, and sets the setgid bit so new files inherit the group. The GID is also added to each container's supplemental groups. This is how a container running as a random non-root UID gets to write to its PersistentVolume.
Three gotchas, in increasing order of pain:
It only applies to some volume types. emptyDir, secret, configMap, and PVCs whose CSI driver supports fsGroup get the treatment — that means fsGroupPolicy: File, or the default ReadWriteOnceWithFSType when the volume is ReadWriteOnce with an fstype (which covers typical block-storage PVCs like EBS). hostPath is never touched. NFS and most ReadWriteMany filesystems are typically skipped. If you are staring at permission denied on an NFS mount wondering why fsGroup "isn't working" — it is not supposed to. Fix ownership on the export or use an init container.
The recursive chown happens at mount time, every time, by default. On a volume with millions of files, fsGroupChangePolicy: Always (the default) means minutes of ContainerCreating while the kubelet walks the tree. Set fsGroupChangePolicy: "OnRootMismatch" so the walk is skipped when the volume root already has the right ownership — for stateful workloads with large volumes this is close to mandatory.
It changes shared state, not per-container state. Two pods mounting the same ReadWriteMany volume with different fsGroup values will fight over ownership on every mount. Pick one GID per shared volume and standardise it.
Kubernetes Production Readiness Checklist
The pre-launch checks we run before calling a cluster production-ready — probes, resources, RBAC, upgrades, and backups. Plain Markdown you can commit to your repo.
Free. Instant download. You'll also get the occasional deep-dive from the newsletter — unsubscribe anytime.
seccompProfile: set it at the pod level
seccompProfile exists at both levels, and the right default is boring: set type: RuntimeDefault once, at the pod level.
spec:
securityContext:
seccompProfile:
type: RuntimeDefaultThe container level exists for the exception — one container needing a custom Localhost profile while the rest keep the runtime default. What you should never ship is Unconfined, and what you should never rely on is omission: with no profile set anywhere, most container runtimes run the container unconfined, with every syscall available. The Pod Security Standards restricted profile enforces this: seccompProfile.type must be RuntimeDefault or Localhost, set either at the pod level or explicitly on every container. Pod level is one line; per-container is N lines and a missed sidecar.
A realistic hardened pod, both levels
Here is the shape that passes the restricted Pod Security Standard and reflects how the two levels divide the work:
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4 name: payments-api
5spec:
6 replicas: 3
7 selector:
8 matchLabels:
9 app: payments-api
10 template:
11 metadata:
12 labels:
13 app: payments-api
14 spec:
15 securityContext: # pod level: identity + volumes
16 runAsNonRoot: true
17 runAsUser: 10001
18 runAsGroup: 10001
19 fsGroup: 10001
20 fsGroupChangePolicy: "OnRootMismatch"
21 seccompProfile:
22 type: RuntimeDefault
23 containers:
24 - name: api
25 image: registry.example.com/payments-api:1.8.3
26 ports:
27 - containerPort: 8080 # ≥1024, so no NET_BIND_SERVICE needed
28 securityContext: # container level: process restrictions
29 allowPrivilegeEscalation: false
30 readOnlyRootFilesystem: true
31 capabilities:
32 drop: ["ALL"]
33 volumeMounts:
34 - name: tmp
35 mountPath: /tmp
36 - name: data
37 mountPath: /var/lib/payments
38 volumes:
39 - name: tmp
40 emptyDir: {}
41 - name: data
42 persistentVolumeClaim:
43 claimName: payments-dataNote the pairing of readOnlyRootFilesystem: true with an emptyDir mounted at /tmp. A read-only root filesystem is one of the highest-value hardening settings you can apply — writable-root is what most drop-a-binary attacks depend on — but nearly every runtime wants somewhere to write temp files. Give it a volume; volumes are unaffected by the root filesystem being read-only. If you are assembling manifests like this from scratch, our Kubernetes Deployment Generator scaffolds the security context blocks alongside probes and resources.
And note the port: binding to 8080 instead of 80 is what lets drop: ["ALL"] stand without adding NET_BIND_SERVICE back. Changing the listen port is almost always cheaper than carrying a capability.
Validating it actually took effect
A manifest that declares the right fields isn't proof they applied — verify against the running container:
1# Check what user the container is actually running as
2kubectl exec my-pod -c app -- id
3# uid=1000(app) gid=1000(app) groups=1000(app),2000
4
5# Confirm capabilities were actually dropped
6kubectl exec my-pod -c app -- cat /proc/1/status | grep -E "^(Cap|No)"
7
8# Confirm the root filesystem is genuinely read-only
9kubectl exec my-pod -c app -- touch /test-write 2>&1
10# touch: /test-write: Read-only file systemIf a pod is subject to the Restricted Pod Security Standards profile, admission itself already enforces most of this and will reject non-compliant pods outright — but that's namespace-wide enforcement, not per-pod proof of what a specific running container is doing right now. Use audit or warn mode first on existing clusters so you see which pods would fail before anything actually breaks.
Common mistakes
runAsNonRoot: true at pod level, but the image runs as root. The most common failure by far. runAsNonRoot does not make anything run as non-root — it is an assertion the kubelet verifies at container start. If the image's USER is root and no runAsUser overrides it, the container fails with CreateContainerConfigError: container has runAsNonRoot and image will run as root. Related trap: an image with a named user (USER app) fails too — cannot verify user is non-root — because the kubelet can only verify numeric UIDs. Either set a numeric runAsUser in the manifest or a numeric USER 10001 in the Dockerfile. The image and the manifest are two halves of one contract, which is why container image security and pod hardening have to be done together.
Expecting pod-level capability drops. capabilities simply does not exist in PodSecurityContext. Every container — including inits and sidecars — needs its own drop: ["ALL"].
Assuming fsGroup fixes hostPath or NFS permissions. Covered above, but it accounts for a remarkable share of "securityContext is broken" reports. It is not broken; the volume type is out of scope.
Confusing allowPrivilegeEscalation with privileged. privileged: true grants roughly host-root. allowPrivilegeEscalation: false is far narrower — it sets no_new_privs, blocking setuid binaries and file capabilities from raising privileges beyond the parent process. You want it false essentially everywhere; left unset, the effective default is true — and the API forces it true when the container is privileged or adds CAP_SYS_ADMIN.
Hardening app containers and forgetting init containers. Pod-level fields cover them automatically; container-level fields do not. When auditing, check spec.initContainers[*].securityContext explicitly — it is where privileged chown hacks hide, usually working around an fsGroup that would have solved the problem properly.
How this maps to Pod Security Standards
The Pod Security Standards restricted profile is effectively a checklist over these exact fields, and Pod Security Admission enforces it per namespace. What restricted demands, and at which level you can satisfy it:
| Requirement | Where it can be set |
|---|---|
runAsNonRoot: true | Pod level, or every container |
allowPrivilegeEscalation: false | Every container (no pod-level option) |
capabilities.drop: ["ALL"] | Every container (no pod-level option) |
capabilities.add — only NET_BIND_SERVICE | Per container, if at all |
seccompProfile — RuntimeDefault or Localhost | Pod level, or every container |
privileged — must not be true | Per container (just don't) |
The pattern is visible: everything that can live at the pod level, set at the pod level once; the container-only fields are the repetitive part, and they are exactly what an admission check catches when a new sidecar arrives without them. Pod Security Admission gives you namespace-level enforcement out of the box; pair it with RBAC so that only the right people can relabel namespaces — our RBAC generator builds those roles quickly.
Notice also what restricted does not require: readOnlyRootFilesystem. It is one of the strongest settings available and the standards leave it optional because too many workloads break without a tmp volume. Treat it as your own baseline anyway — the fix (an emptyDir) is two lines.
Frequently Asked Questions
What is the difference between PodSecurityContext and SecurityContext?
PodSecurityContext is the schema of spec.securityContext — pod-wide defaults (runAsUser, seccompProfile) plus pod-only volume and kernel settings (fsGroup, supplementalGroups, sysctls). SecurityContext is the schema of spec.containers[*].securityContext — per-process restrictions including the container-only fields capabilities, privileged, allowPrivilegeEscalation, readOnlyRootFilesystem, and procMount.
Which wins when both levels set the same field?
The container level, for that container, field by field. A container overriding runAsUser still inherits the pod's runAsGroup and seccompProfile. Fields that exist only at the pod level, like fsGroup, cannot be overridden or opted out of per container.
Why can't I set capabilities at the pod level?
Because capabilities are a property of an individual Linux process, the API only models them per container — PodSecurityContext has no capabilities field, and a manifest that puts one there fails validation. Every container, including init containers, needs its own drop: ["ALL"].
Why is my pod failing with CreateContainerConfigError after setting runAsNonRoot?
Because runAsNonRoot: true is a verification, not a conversion. The kubelet checks the effective UID at start; if the image runs as root (or as a named, non-numeric user it cannot verify), the container is refused. Set a numeric runAsUser at the pod or container level, or bake a numeric USER into the image.
Does fsGroup work on every volume?
No. It applies to emptyDir, secret, configMap, and PVCs whose CSI driver supports it — fsGroupPolicy: File, or the default ReadWriteOnceWithFSType for ReadWriteOnce volumes with an fstype. It never touches hostPath and is typically skipped for NFS-style ReadWriteMany volumes. For large volumes, set fsGroupChangePolicy: "OnRootMismatch" to avoid a full recursive chown on every mount.
Where should seccompProfile go — pod or container?
Pod level, as type: RuntimeDefault, unless a specific container needs a custom Localhost profile — then override just that container. The restricted Pod Security Standard accepts either placement but requires that no container ends up unconfined, and one pod-level line is much harder to get wrong than one line per container.
Official References
- Pod Security Standards — the Privileged, Baseline and Restricted profiles
- Configure a security context — runAsUser, capabilities and seccomp fields
Was this article helpful?
Be the first to rate this article
Related Topics
Found this useful? Share it.


