EduFlow

Section 2 · Production-Grade Images

Small Bases and Non-Root Users

20 min4 min readSpot the bug

Lesson at a glance

Choose a base image for notes-api by weighing slim, Alpine and distroless, run the app as a non-root user, and lock the container down further at run time.

  1. 1Choosing a base
  2. 2Stop running as root
  3. 3The distroless version
  4. 4Lock it down at run time

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

In 2024 a Skylane image failed a customer’s security review with 140 findings. Fewer than five were in code we wrote or called. The rest were in packages the base image happened to include: an XML library, an image converter, a mail transfer agent. None of them ran, and all of them were our problem. Your base image is a dependency you inherit wholesale, so choose it deliberately.

Choosing a base

Base What’s inside Good for Watch out for
node:24 Full Debian, compilers, many libraries Build stages that compile native addons Large; lots to scan
node:24-slim Minimal Debian + Node Most runtime images Still has a shell and apt
node:24-alpine Alpine Linux (musl) + Node Very small images musl vs glibc differences
gcr.io/distroless/nodejs24-debian13 Node and its runtime libraries only Hardened production images No shell, no package manager

My default for a team new to containers is slim: small, glibc-based so prebuilt native modules work, and you can still docker exec into it with a shell when something’s on fire.

Alpine gets you smaller images, but it uses the musl C library instead of glibc. Prebuilt native addons compiled for glibc fail to load, and subtle differences in DNS resolution and memory allocation have bitten teams in production. Choose it when you’ve tested your full dependency tree on it, not because the number on the tag page is lower.

Distroless images contain Node.js and the libraries it needs, and nothing else: no shell, no apt, no curl. An attacker who gets code execution finds very little to work with, and the scanner has very little to report. The cost is debuggability. You can’t exec a shell into it, and you’ll learn the Kubernetes way around that, kubectl debug, in the last lesson of this course.

Stop running as root

By default, the process in a container runs as root, UID 0. Namespaces limit what that root can see, but it’s still root as far as the kernel is concerned. Combined with a kernel bug or a misconfigured mount, that is how a container compromise becomes a host compromise. Running as an unprivileged user turns many of those bugs into dead ends.

The official Node.js images ship a node user with UID 1000. Switch to it in the runtime stage, after anything that needs root:

Dockerfile
# …build and deps stages from the previous lesson

FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Note what’s not there: no --chown=node:node on the copies. The files stay owned by root and readable by everyone, so the app can read its code but can’t change it. If an attacker finds a remote-code-execution bug, they can’t rewrite dist/server.js to persist. Give the app write access only where it genuinely needs to write, and prefer /tmp or a mounted volume for that.

The distroless version

Here’s the same runtime stage on distroless. The :nonroot tag runs as UID 65532 out of the box, and the image’s entrypoint is already node, so CMD lists only the script:

Dockerfile
FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["dist/server.js"]

The build and deps stages still use node:24-slim; only the shipped stage changes. Keep the Node major version the same across stages; otherwise native modules compiled against one Node version’s ABI end up loading in another and fail at startup.

You can confirm who the container will run as without starting it:

Terminal
docker image inspect notes-api:1.0.0 --format '{{.Config.User}}'

Lock it down at run time

The image sets defaults; the runtime can tighten them further:

Terminal
docker run --rm -p 8080:3000 \
  --read-only --tmpfs /tmp \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  notes-api:1.0.0

--read-only makes the container’s root filesystem immutable, with /tmp as a small writable in-memory mount. --cap-drop ALL removes the Linux capabilities that root-ish operations need. no-new-privileges stops a process from gaining privileges through setuid binaries. In section 5 you’ll set the same things in a Kubernetes securityContext, where they become policy for every Pod.

Your image is now small and unprivileged. Next: how to name it so that what you deploy is exactly what you tested.

Key takeaways

  • Every package in the base image is something you patch, scan and defend, so start from the smallest base your app really runs on.
  • node:24-slim is a solid default; Alpine uses musl instead of glibc, and distroless removes the shell and package manager entirely.
  • Official Node.js images include a node user (UID 1000); switch to it with USER node after the steps that need root.
  • Leave application files owned by root and read-only to the app user, so a compromised process can’t rewrite its own code.
  • Add runtime hardening such as --read-only, --cap-drop ALL and no-new-privileges; Kubernetes has the same controls in securityContext.

Further reading

Check your understanding

4 questions

Question 1 of 4

notes-api depends on a package with a native addon. After switching the base from node:24-slim to node:24-alpine, the container crashes on start with an error loading a .node file. What’s the likely cause?

Question 2 of 4

Which change makes notes-api run as a non-root user in the official Node.js image?

Question 3 of 4

You move notes-api to gcr.io/distroless/nodejs24-debian13:nonroot. Which CMD is correct?

Question 4 of 4

Why leave notes-api’s dist/ files owned by root even though the process runs as node?

Practice

Spot the bug

The temporary fix that shipped

A security review flags this image: “container process runs as UID 0”. The author insists they switched to the node user. Select the line that makes the container run as root, and think about what the right fix for the original error would be.

Select the line(s) you think are wrong, then check.