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 /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'
okThen 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 directoryGone. 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_realThe 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 \
npm cidocker 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:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07dThe 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:devWhat 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.