Drop root: non-root by default
USER, numeric UIDs, and file ownership.
The highest-value line in any hardening effort is: do not run as root. Because container root is host root against a shared kernel, a non-root container turns a breakout from “attacker owns the host” into “attacker is an unprivileged user with almost nothing to do.” You establish it in two places — bake a non-root user into the image, and enforce non-root at run time so a rebuilt or third-party image cannot quietly go back to root.
FROM alpine:3.20# create a dedicated, fixed-UID user — do not reuse a system accountRUN addgroup -g 10001 app && adduser -u 10001 -G app -S -D appWORKDIR /appCOPY --chown=10001:10001 . .USER 10001:10001 # numeric UID:GID — works even with runAsNonRoot enforcementENTRYPOINT ["/app/server"]
Use a numeric UID, not just a name
Prefer USER 10001:10001 over USER app. A numeric UID lets the platform verify the container is non-root (Kubernetes runAsNonRoot checks the UID, and cannot tell whether the name “app” maps to 0), and it avoids surprises when /etc/passwd is absent, as on scratch and distroless. Own the app’s files to that UID at COPY time with --chown so the non-root process can actually read what it needs.
# enforce non-root at run time, independent of what the image declares$ docker run --rm --user 10001:10001 myapp:1.0 iduid=10001 gid=10001# Kubernetes equivalent, which rejects a root image at admission:# securityContext: { runAsNonRoot: true, runAsUser: 10001 }
The files a non-root process needs to write
Dropping root breaks apps that assumed they could write anywhere — a cache dir, a PID file, a socket. The fix is not to go back to root but to pre-create those paths owned by the app UID (or mount them as writable volumes/tmpfs), so the process writes exactly where it should and nowhere else. This pairs directly with the read-only root filesystem in the next lesson.