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.
Shield flags containers that run as root, so you catch the ones that never dropped privileges.
See what Shield finds in your code
Free to start, no credit card. One pass covers SAST, secrets, vulnerable dependencies, containers and IaC — ranked by what’s actually reachable, not just severity.
Scan your repo freePrefer the terminal? The offline CLI runs with no account