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.
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:
# …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:
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:
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:
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-slimis a solid default; Alpine uses musl instead of glibc, and distroless removes the shell and package manager entirely.- Official Node.js images include a
nodeuser (UID 1000); switch to it withUSER nodeafter 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 ALLandno-new-privileges; Kubernetes has the same controls insecurityContext.