Fix 'container has runAsNonRoot and image will run as root' (2026)

Nov 10, 2026 · 12 min · Ajay Kumar

This guide is for anyone whose pod is stuck in CreateContainerConfigError with "container has runAsNonRoot and image will run as root", or who just turned on Pod Security "restricted" and watched half their workloads fail. By the end you will have an image that runs as a numeric non-root UID, a securityContext that passes the Restricted standard, and fixes for the errors that usually follow: permission denied on volumes, read-only file system writes, and binding port 80. Everything here is checked against Kubernetes v1.37.2 source and docs, containerd 2.x and runc 1.2+. You need kubectl access to a test namespace and the ability to rebuild the image. If you want the background on what each field does, read the companion post, Kubernetes securityContext Explained.

Step 1: Read the exact error message

The kubelet emits three different messages from the same check, and each has a different fix. Start by getting the precise text.

# Replace web and the namespace with your own
kubectl -n shop get pods -l app=web
kubectl -n shop describe pod -l app=web | sed -n '/State:/,/Events:/p;/Events:/,$p'

Expected output for the most common case:

    State:          Waiting
      Reason:       CreateContainerConfigError
...
  Warning  Failed  12s (x3 over 30s)  kubelet  Error: container has runAsNonRoot and image will run as root (pod: "web-6c9f8d7b5-x2k4p_shop(3f1c...)", container: web)

The strings come straight from the kubelet's verifyRunAsNonRoot, and its logic is simple:

Message What the kubelet saw Go to
image will run as root runAsUser unset, image user is UID 0 (or unset) Step 3
image has non-numeric user (node), cannot verify user is non-root runAsUser unset, image user is a name Step 4
container's runAsUser breaks non-root policy runAsUser: 0 with runAsNonRoot: true Remove runAsUser: 0
image has an invalid user id UID outside 1 to 2147483647 Use a normal UID

If you see nothing like that and the pod was never created at all, the rejection came from Pod Security Admission instead; jump to Step 7.

Step 2: Find out which user the image runs as

The kubelet only looks at the User field in the image config. It never reads /etc/passwd inside the image, which is why names fail.

# With Docker (pulls the image if needed)
docker image inspect --format '{{.Config.User}}' node:24-bookworm-slim
# (empty line = root)

# Without a daemon, using the registry API via regctl or crane if you have them
# crane config node:24-bookworm-slim | jq -r '.config.User'

# Or ask the running container, if it starts under a permissive namespace
kubectl -n scratch run whoami --rm -it --restart=Never \
  --image=node:24-bookworm-slim -- id
# expected: uid=0(root) gid=0(root) groups=0(root)

An empty User means root. A value like node or appuser is a name and will trigger the non-numeric error. A value like 1000 or 10001:10001 is what you want.

runAsNonRoot and securityContext errors: which fix applies Pod fails to run Was the pod object created at all? no PodSecurity rejection (Step 7) add drop ALL, no escalation, seccomp yes CreateContainerConfigError: check image Config.User (Step 2) empty or 0: numeric USER or runAsUser a name: switch to the numeric UID Container starts, then fails: permission denied on volume = fsGroup; read-only file system = emptyDir mounts; bind on port 80 = use 8080 Image genuinely needs root inside the container: set spec.hostUsers: false (GA in v1.36). PSA then skips runAsNonRoot and runAsUser checks; needs Linux 6.3+, containerd 2.0+ or CRI-O 1.25+.
Decision flow built from the kubelet's verifyRunAsNonRoot checks (v1.37.2), the Pod Security Admission checks, and the Kubernetes user namespaces documentation.

Step 3: Fix "image will run as root" with a numeric USER

The durable fix belongs in the image, so every environment gets it, not just the namespace where someone remembered the manifest. Create a user with a fixed UID and switch to it by number.

# Dockerfile: Python service, non-root by UID
FROM python:3.13-slim-bookworm

# Fixed UID/GID above the usual system and login ranges
RUN groupadd --gid 10001 app \
 && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin app

WORKDIR /app
COPY --chown=10001:10001 requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=10001:10001 . .

# Numeric, so the kubelet can prove it is not root
USER 10001:10001
EXPOSE 8080
CMD ["python", "-m", "gunicorn", "-b", "0.0.0.0:8080", "app:app"]

On Alpine the equivalent is addgroup -g 10001 app && adduser -u 10001 -G app -S -D app. If you build on distroless, the :nonroot tags ship a user with UID 65532, so use USER 65532:65532.

If you cannot rebuild the image today (a vendor image, say), set the UID in the manifest instead. Check first that the app does not need to write anywhere owned by root.

# deployment.yaml (fragment): override the image user
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001    # without this the primary GID stays 0

Then roll it out and confirm:

kubectl -n shop apply -f deployment.yaml
kubectl -n shop rollout status deploy/web --timeout=120s
kubectl -n shop exec deploy/web -- id
# expected: uid=10001 gid=10001 groups=10001

Step 4: Fix "image has non-numeric user, cannot verify user is non-root"

This one catches official images that do the right thing in a way Kubernetes cannot check. The Node.js images, for example, create a node user with UID 1000, and their docs tell you to add USER node. That name is exactly what triggers the error.

FROM node:24-bookworm-slim
WORKDIR /app
COPY --chown=1000:1000 . .
RUN npm ci --omit=dev
# was: USER node  (name, kubelet cannot verify)
USER 1000:1000
CMD ["node", "server.js"]

Or leave the image alone and set runAsUser: 1000 in the pod. Either works, because the kubelet checks runAsUser before it looks at the image.

Step 5: Fix permission denied on volumes with fsGroup

Once the process is non-root, a PersistentVolume formatted and owned by root rejects writes: EACCES, Permission denied, or Postgres complaining that its data directory has wrong ownership. Set fsGroup at pod level so the kubelet gives the group ownership of the volume at mount time.

spec:
  securityContext:
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    fsGroupChangePolicy: OnRootMismatch   # skip the recursive chown if the root already matches
  containers:
    - name: web
      volumeMounts:
        - name: data
          mountPath: /var/lib/app
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: web-data
kubectl -n shop exec deploy/web -- ls -ld /var/lib/app
# expected (illustrative): drwxrwsr-x 3 root 10001 4096 Nov 10 09:12 /var/lib/app

If ownership did not change, the volume type is not one the kubelet adjusts. For CSI volumes, check the driver's policy:

kubectl get csidriver -o custom-columns=NAME:.metadata.name,FSGROUP:.spec.fsGroupPolicy

None means the driver never applies fsGroup. The default, ReadWriteOnceWithFSType, only applies it when the PV has an fsType and a ReadWriteOnce access mode, so many RWX file shares are skipped (CSI docs). hostPath is never adjusted. For those, fix ownership on the server side or, as a last resort, use an init container that runs chown as root (which Restricted will not allow, so it needs its own namespace policy).

Step 6: Fix "Read-only file system" and ports below 1024

With readOnlyRootFilesystem: true, any write outside a volume fails with errno 30, for example OSError: [Errno 30] Read-only file system: '/tmp/...'. Find the paths the app writes to and give each one an emptyDir.

containers:
  - name: web
    securityContext:
      readOnlyRootFilesystem: true
    volumeMounts:
      - { name: tmp, mountPath: /tmp }
      - { name: cache, mountPath: /home/app/.cache }
volumes:
  - name: tmp
    emptyDir: { sizeLimit: 64Mi }
  - name: cache
    emptyDir: { sizeLimit: 256Mi }

To find the paths quickly, run the container once without the flag and diff the filesystem: docker container diff <id> lists every file the process created or changed.

Port 80 is the other classic. A non-root process gets bind: permission denied on ports below 1024 unless the kernel's net.ipv4.ip_unprivileged_port_start has been lowered. Adding NET_BIND_SERVICE to capabilities.add usually does not help a non-root process, because Kubernetes does not set ambient capabilities (kubernetes/kubernetes#56374, still open). Your options, best first:

# 1. Listen on 8080 and let the Service expose 80
apiVersion: v1
kind: Service
metadata: { name: web, namespace: shop }
spec:
  selector: { app: web }
  ports:
    - port: 80
      targetPort: 8080
---
# 2. Or lower the unprivileged port floor per pod (a "safe" sysctl, allowed by Baseline and Restricted)
spec:
  securityContext:
    sysctls:
      - name: net.ipv4.ip_unprivileged_port_start
        value: "0"

On containerd 2.x you may not need either: its CRI plugin sets enable_unprivileged_ports = true by default, which applies that sysctl to non-host-network pods. The containerd CRI config docs note it defaulted to false before 2.0, and CRI-O leaves it alone unless you add it to default_sysctls. That is why the same image binds port 80 on one cluster and fails on another.

Step 7: Fix "violates PodSecurity restricted:latest"

If the namespace enforces Restricted, the API server rejects the pod before the kubelet sees it. For a Deployment, the Deployment itself is accepted (with a warning only if the namespace also has a warn label) and the ReplicaSet fails to create pods, so look at its events. The reasons are fixed strings from the pod-security-admission checks:

Error from server (Forbidden): pods "web" is forbidden: violates PodSecurity "restricted:latest":
allowPrivilegeEscalation != false (container "web" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "web" must set securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "web" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "web" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

The block that clears all four:

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    seccompProfile: { type: RuntimeDefault }
  containers:
    - name: web
      securityContext:
        allowPrivilegeEscalation: false
        capabilities: { drop: ["ALL"] }

Before enforcing, test a namespace without breaking it. A server-side dry run of the label shows every existing pod that would fail:

kubectl label --dry-run=server --overwrite ns shop \
  pod-security.kubernetes.io/enforce=restricted
# expected: Warning: existing pods in namespace "shop" violate the new PodSecurity enforce level "restricted:latest"

For policy-as-code instead of labels, see How to Enforce Pod Security Standards with Kyverno.

Step 8: When the image really needs root, use hostUsers: false

Some images cannot be made non-root cheaply: they apt install at start, or chown files at boot. Since v1.36, user namespaces are GA. Root inside the pod maps to an unprivileged, unique UID range on the node, and Pod Security Admission skips the runAsNonRoot and runAsUser checks for these pods, per the user namespaces docs.

spec:
  hostUsers: false          # pod spec field, not inside securityContext
  securityContext:
    seccompProfile: { type: RuntimeDefault }
  containers:
    - name: legacy
      image: registry.example.com/legacy:3.2.0
      securityContext:
        allowPrivilegeEscalation: false
        capabilities: { drop: ["ALL"] }

Requirements: Linux 6.3+ on the node, containerd 2.0+ or CRI-O 1.25+, runc 1.2+ or crun 1.9+, and no hostNetwork, hostPID or hostIPC. Check from inside: kubectl exec ... -- cat /proc/self/uid_map shows a mapping like 0 1835008 65536 (the host start is always a multiple of 65536) instead of 0 0 4294967295.

Troubleshooting

Symptom Likely cause Fix
CreateContainerConfigError, "image will run as root" Image has no USER, runAsUser unset Numeric USER in Dockerfile or runAsUser
"image has non-numeric user (node)" USER node or another name USER 1000:1000 or runAsUser: 1000
"runAsUser breaks non-root policy" runAsUser: 0 with runAsNonRoot: true Remove the 0 or use hostUsers: false
Permission denied writing to a PVC Volume owned by root fsGroup plus fsGroupChangePolicy: OnRootMismatch
fsGroup set, ownership unchanged CSI fsGroupPolicy: None, RWX volume, or hostPath Fix ownership server-side or change driver policy
[Errno 30] Read-only file system readOnlyRootFilesystem: true emptyDir on each write path
bind: permission denied on port 80 Non-root, port floor 1024, no ambient caps Use 8080 behind the Service, or the sysctl
Pod binds 80 on one cluster, fails on another containerd 2.x vs CRI-O or containerd 1.7 defaults Set the sysctl explicitly in the pod
violates PodSecurity "restricted:latest" Missing drop ALL, escalation, seccomp, non-root Add the four fields from Step 7
hostUsers: false pod rejected Combined with host namespaces or volumeDevices Remove them or keep the pod on host users

FAQ

What does "container has runAsNonRoot and image will run as root" mean?

It means the pod or container sets runAsNonRoot: true, no runAsUser is set, and the image's configured user is UID 0 or empty. The kubelet refuses to create the container and the pod sits in CreateContainerConfigError. Set a numeric non-root USER in the image or a runAsUser in the pod.

Why does USER appuser fail when appuser is not root?

The kubelet only reads the User string from the image config and does not resolve names against /etc/passwd in the image. A name cannot be proven non-root, so it fails with the non-numeric user error. Use the numeric UID instead.

Does fsGroup work on every volume type?

No. It works on most block-backed PVCs, emptyDir, Secrets and ConfigMaps, but CSI drivers can opt out with fsGroupPolicy: None, the default policy skips volumes without an fsType or a ReadWriteOnce access mode, and hostPath is never changed.

Can I just add NET_BIND_SERVICE to bind port 80?

For a container running as root with dropped capabilities, yes. For a non-root process it usually has no effect, because Kubernetes does not set ambient capabilities. Listen on a high port, or rely on net.ipv4.ip_unprivileged_port_start.

When a pod fails like this in production, the first thing I check is docker image inspect --format '{{.Config.User}}' on the exact digest that is deployed, because the usual story is that the image changed and the manifest did not. Then I fix it in the Dockerfile rather than the manifest, so the next chart or the next agent-generated Deployment cannot forget it. Official references: Configure a Security Context, Pod Security Standards, User Namespaces and Seccomp.

Related: How to Fix Kubernetes CrashLoopBackOff: Causes and Commands (2026), How to Enforce Pod Security Standards with Kyverno 1.19 (2026) and Hardened Container Images in 2026: DHI Free, Bitnami Gone, 515 CVEs to 0.

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related