Docker – Don't Run as Root

July 9, 20264 min readUpdated 8/21/2026

Two things in this lesson are worth more than every scanner report you will ever read: the user your container runs as, and the fact that a deleted file is still in your image.

Your container is running as root

Unless you said otherwise. Check any image you use:

$ docker run --rm --entrypoint id nginx:1.27-alpine
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),...

Root inside the container is uid 0 on the kernel it shares with your host. Namespaces make that mostly harmless most of the time — but "mostly" is doing real work in that sentence. A container escape, a mounted host path, a socket handed in for convenience, and uid 0 in the container is uid 0 outside it.

The two images in this track:

$ docker run --rm --entrypoint id pizza-api:dev
uid=999(pizza) gid=999(pizza) groups=999(pizza)

$ docker compose exec web id
uid=101(nginx) gid=101(nginx) groups=101(nginx)

Doing it

RUN groupadd --system pizza && useradd --system --gid pizza --no-create-home pizza
WORKDIR /app
COPY --from=build --chown=pizza:pizza /build/extracted/application/ ./
RUN mkdir -p /app/uploads/products && chown -R pizza:pizza /app/uploads
USER pizza

--system gives an account with no password, no home directory and no login shell — an identity to run as, not a person.

--chown on the COPY rather than a RUN chown afterwards is worth the habit. A separate chown rewrites every file's metadata, which for a copy-on-write filesystem means a second full copy of those files in a new layer. On the 120 MB dependency layer that is 120 MB of pure waste.

USER goes as late as possible — everything after it runs unprivileged, including remaining build steps, so package installs and directory creation come first.

Verify it does what you think:

$ docker run --rm --entrypoint sh pizza-api:dev -c 'touch /etc/probe'
touch: cannot touch '/etc/probe': Permission denied

$ docker run --rm --entrypoint sh pizza-api:dev -c 'touch /app/uploads/probe && echo ok'
ok

Then port 80 stops working

The predictable next problem. Ports below 1024 are privileged, and a non-root process cannot bind one:

bind() to 0.0.0.0:80 failed (13: Permission denied)

The wrong fix is to go back to root. The right one is to listen on a high port and publish it wherever you like — the host side of a publish is bound by the Docker daemon, which is root, so -p 80:8080 works fine.

That is exactly what nginxinc/nginx-unprivileged does. The official nginx image starts as root to bind 80 and drops privileges for its workers; the unprivileged one never runs as root at all, and listens on 8080:

server {
    listen       8080;
    server_name  localhost;
ports:
  - "8080:8080"

A deleted secret is not deleted

This one is worth demonstrating rather than asserting, because the image really does look clean.

FROM alpine:3.21
COPY secrets.env /tmp/secrets.env
RUN cat /tmp/secrets.env > /dev/null
RUN rm /tmp/secrets.env
$ docker run --rm leakdemo:v1 cat /tmp/secrets.env
cat: can't open '/tmp/secrets.env': No such file or directory

Gone. Now unpack the image instead of running it:

$ docker save leakdemo:v1 -o img.tar && tar -xf img.tar -C export
$ tar -xOf export/blobs/sha256/0d27be0959... tmp/secrets.env
API_KEY=sk_live_pretend_this_is_real

The COPY layer holds the file. The rm layer holds a whiteout marker that hides it. Both ship, and anyone who can pull the image can do what you just did — no exploit, no privileged access, two standard commands.

The same applies to ARG and ENV: build arguments are recorded in the image metadata and docker history prints them.

The fix is a BuildKit secret mount, which makes the value available during one RUN and puts it in no layer:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .

And the fix for the far more common case — a secret that was never needed at build time at all — is to pass it at run time. Lesson 12.

Keep it out of the context in the first place

Best of all, the file the builder never receives cannot end up in a layer by accident. .dockerignore is a security control as much as a speed one:

src/main/resources/application-local.properties
src/main/resources/application-*.local.properties
.env
.env.local
.git/

.git/ deserves its own mention: it holds every version of every file ever committed, including a secret that was committed once and removed in the next commit.

Pin the base image by digest

A tag is a mutable pointer (lesson 3). For anything you care about reproducing:

FROM alpine@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d

The honest trade: you now get no security updates until someone changes that line. Pinning without a bot like Dependabot or Renovate to bump it is how a base image ends up two years stale. Pin plus automation, or a tag plus a rebuild schedule — but pick one deliberately.

Scanning

docker scout quickview pizza-api:dev
docker scout cves pizza-api:dev

⚠️ docker scout requires a Docker Hub login, so there is no output to show you here — this track was written without signing in. Trivy is the usual alternative and needs no account:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy image pizza-api:dev

What to expect: a long list, mostly in the base image, mostly in packages your application never calls. Which is why the ordering in this lesson is what it is. A scanner tells you which CVEs are present. It cannot tell you that your container is running as root, or that a credential is sitting in layer three.

Next: how containers reach each other, and why localhost lies.