Do popular container images drop root? We checked 25 projects
Half of the primary Dockerfiles in 25 popular infra and app projects don't set an explicit non-root USER in their final stage. A fair, caveated look at what static analysis can and can't tell you about container privilege.
TL;DR — Across 25 popular infrastructure and application projects, we looked at
whether their Dockerfiles set a non-root USER. Of the 16 projects with a clear
primary image, exactly half (8) don't set an explicit non-root USER in the
final build stage. But "no USER line" is not the same as "insecure" — this post
is as much about that distinction as about the number.
Why a non-root USER matters (and when it doesn't)
If a container's final image doesn't drop to a non-root user, a process that breaks
out of the app runs as root inside the container — a bigger blast radius if it then
finds a kernel or runtime escape. Setting USER app (a non-root uid) is the
documented defense-in-depth default in Docker's and CIS's hardening guidance.
But three honest caveats mean "no USER line" ≠ "vulnerable", and we lead with them
because a benchmark that hides its limits isn't one you should trust:
- A static check can't see into base images.
FROM some/imagethat already sets a non-rootUSERinternally is invisible to a Dockerfile scanner — so a file with noUSERmay still run non-root at runtime. - It can't see runtime privilege drops. Some images stay root in the Dockerfile
but drop to a non-root user at startup via
gosu/su-exec/an entrypoint (gitea does exactly this). The container isn't actually running as root. - Minimal images blur it. A static binary on
scratch/distroless/alpineoften runs as root with a tiny attack surface, where "root" means much less.
So the precise, checkable claim here is "the Dockerfile does not set an explicit
non-root USER" — a defense-in-depth smell, not a proof of vulnerability. With
that framing set, here's what we found.
What we measured
- Corpus: 25 popular infra/app repos; 22 ship at least one Dockerfile.
- Tool: Zennoxa Shield's
CONTAINER-012— multi-stage-aware; it fires only when the final stage never sets a non-rootUSER(an earlier build stage doesn't count). Hand-verified against a sample (traefik/minio →alpine, noUSER; prometheus →USER nobody; vault →USER ${NAME}). - Two cuts, because one number would mislead: all Dockerfiles (which sweeps in
dev/test fixtures where root is fine), and primary image only (the top-level
./Dockerfile— the thing most likely actually shipped).
Full per-repo data in data.json.
The numbers
| Cut | No non-root USER |
Share |
|---|---|---|
| Primary image only (16 repos) | 8 | 50.0% |
| All Dockerfiles (195 files) | 110 | 56.4% |
The "all Dockerfiles" figure is inflated by dev/test fixtures (local databases,
proxies) where root is irrelevant — which is exactly why we don't lead with it. The
primary-image cut is the fair headline: half of these projects' main Dockerfiles
don't set a non-root USER.
Primary image sets a non-root USER: prometheus, vault, consul, posthog,
mastodon, airflow, superset, netdata.
Primary image does not (final stage): grafana, moby, traefik, minio, appwrite, home-assistant, gitea, gogs — noting that some of these (e.g. gitea) drop privileges at runtime instead, per caveat #2.
What to actually do
Setting an explicit non-root user is a one-line change and removes the ambiguity — your image is provably non-root regardless of base-image behavior:
# create + switch to a non-root user in the FINAL stage
RUN adduser -D -u 10001 app
USER app
If you rely on a runtime drop (gosu) or a base image's user, that's defensible —
but it's worth making explicit so a reviewer (or a scanner) can see it. Zennoxa
Shield's CONTAINER-012 flags final stages with no non-root USER if you want it
visible in CI.
Limitations
- Static Dockerfile analysis only — see caveats 1–3 above; the true runtime root rate is plausibly lower than the Dockerfile-text rate.
- 25 popular repos, not a random sample of all container projects.
- Counts the final stage's
USER; doesn't evaluate capabilities,--privileged, or seccomp/AppArmor, which also shape real privilege. - Point-in-time snapshot (2026-07-21).
Reproduce it
git clone --depth 1 --filter=blob:none --no-checkout https://github.com/traefik/traefik t
cd t && git sparse-checkout init --no-cone && git sparse-checkout set '**/Dockerfile' && git checkout
# a Dockerfile whose final stage has no `USER` (non-root) defaults to root:
grep -nE '^\s*(FROM|USER)\b' Dockerfile
Per-repo counts and method in data.json.
Zennoxa Research publishes reproducible security data on public projects with public tooling. No third-party scanner is involved.