Non-Root Users in Images
Summary
- Running as a non-root user shrinks the blast radius of a compromised container; the
USERdirective and correct file ownership make it work. - Insight: A container "root" is real root on the host namespace unless user namespaces remap it, so dropping to a normal UID is a genuine control.
- Key Concepts:
USERdirective, numeric UID,runAsNonRoot, filesystem ownership,fsGroup, read-only root filesystem. - When to Use: Every production image. Pod Security Standards
restrictedeffectively requires it. - Limitations: Ports below 1024, package installs, and some legacy binaries assume root and need adjustment.
Recipe
Create a user, own the app files to it, and switch to a numeric UID before the runtime command.
FROM node:22-slim
WORKDIR /app
COPY --chown=10001:10001 package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=10001:10001 . .
USER 10001
CMD ["node", "server.js"]The --chown flag sets ownership as files are copied, and USER 10001 runs the process as an unprivileged UID.
Working Example
A complete image plus the Kubernetes securityContext that enforces the same posture at runtime.
# syntax=docker/dockerfile:1
FROM python:3.12-slim
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app app
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=10001:10001 . .
USER 10001
EXPOSE 8080
CMD ["python", "-m", "myservice"]apiVersion: apps/v1
kind: Deployment
metadata:
name: myservice
spec:
replicas: 2
selector:
matchLabels:
app: myservice
template:
metadata:
labels:
app: myservice
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
containers:
- name: myservice
image: registry.example.com/myservice:1.0
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}The image drops privileges and the pod enforces it, so even a misbuilt image cannot silently run as root.
Deep Dive
Why the USER directive matters
By default a container runs as root (UID 0). If the process is compromised and the container escapes its isolation, root inside can mean root-adjacent access outside, so dropping to an unprivileged UID limits damage.
Set USER after steps that need root, such as package installs, and keep it as the last identity change before CMD/ENTRYPOINT.
Numeric UID versus username
Prefer a numeric UID like USER 10001. Kubernetes runAsNonRoot validates the effective UID, and it can only confirm a username is non-root by resolving it, which it does not do at admission.
A numeric UID also survives base-image changes that might renumber a named user, and it lets runAsUser in the pod match the image exactly.
Filesystem ownership
The app must own the files it needs to read, and any directory it writes to. Use COPY --chown during build, and fsGroup in the pod to grant group ownership on mounted volumes.
Pair non-root with readOnlyRootFilesystem: true and mount an emptyDir at writable paths like /tmp. This blocks tampering with the app's own binaries.
Privileged ports
Binding a port below 1024 traditionally needs root. Listen on a high port such as 8080 inside the container and map it however you like; a Kubernetes Service can still expose it on 80 or 443.
Alignment between image and pod
The image's USER and the pod's runAsUser should agree. If they diverge, the effective UID at runtime is the pod's value, which can break file ownership set at build time.
Set both to the same numeric UID and own writable paths to it. This makes the image self-consistent whether it runs under Docker locally or under containerd in the cluster, where the pod securityContext takes precedence.
Gotchas
Setting runAsNonRoot: true without a non-root image makes the pod fail to start with a "container has runAsNonRoot and image will run as root" error. The image must define a non-root UID or the pod must set runAsUser.
A named USER app without a numeric UID can trip runAsNonRoot validation. Use a numeric UID to be safe.
Files copied before --chown or created by root steps may be unreadable to the app user. Fix ownership with COPY --chown or a chown in the same layer.
readOnlyRootFilesystem breaks apps that write to their working directory. Redirect writes to a mounted emptyDir volume.
Distroless nonroot images already run as UID 65532; adding your own USER can conflict with file ownership. Match your --chown UID to the base's expectation.
Alternatives
User namespace remapping (rootless Docker, or the kubelet's user-namespace support) maps container root to an unprivileged host UID, giving defense in depth even for images that insist on root.
Running as root but dropping all capabilities and forbidding privilege escalation reduces risk when an image cannot be rebuilt, though it is weaker than a genuine non-root UID.
Admission policy (Pod Security Admission restricted, or a policy engine) enforces non-root cluster-wide, catching images that forgot to set USER.
FAQs
Is container root the same as host root? Without user namespaces, yes - it is the same UID 0. That is why dropping to a non-root UID is a real control.
Why numeric UID instead of a username? Kubernetes runAsNonRoot validates the numeric UID and does not resolve usernames at admission, so a numeric UID is reliable.
How do I let my app write files? Own its directories with COPY --chown and fsGroup, or mount an emptyDir for scratch paths when the root filesystem is read-only.
Can a non-root container bind port 80? Not directly. Listen on a high port and expose it through a Service or port mapping.
What UID does distroless nonroot use? UID 65532. Align your file ownership with it when using gcr.io/distroless/*:nonroot.
Does non-root hurt performance? No. It is a permissions change, not a runtime overhead, so there is no measurable cost.
Related
- Distroless & Minimal Bases
- Dockerfile Basics
- Multi-Stage Builds
- How Image Layers & Union Filesystems Work
- Dockerfiles Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).