Our node image shipped 240 CVEs, most from OS packages we never called. Moving to distroless dropped the count to single digits and cut image size by 70%.
A quarterly security scan flagged our checkout service with 240 known vulnerabilities. I spent an afternoon triaging them and realized something annoying: almost none were in our code or our dependencies. They were in bash, apt, perl, and a pile of shared libraries that came free with node:20. Our app never invoked a shell. We were carrying a full Debian userland to run a single Node process.
That's the case for distroless. You ship the language runtime and your app, and nothing else. No package manager, no shell, no coreutils. The attack surface shrinks because most of it was never yours to begin with.
Here's the before and after for the same service. The node:20-slim image was 245MB. The distroless build came out at 74MB. On the CVE side, Trivy told the story:
trivy image --severity HIGH,CRITICAL checkout:slim
# 61 HIGH, 12 CRITICAL
trivy image --severity HIGH,CRITICAL checkout:distroless
# 4 HIGH, 0 CRITICAL
The four remaining were in a transitive npm dependency, which is exactly where I want my attention to go. Signal instead of noise.
Distroless has no way to install anything, so you build in a normal image and copy the result across. Multi-stage does the work:
FROM node:20-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]
Two details that matter. The nonroot tag means the container runs as UID 65532, not root, which you want regardless of distroless. And the CMD takes no shell form. There's no /bin/sh, so CMD npm start won't work. You point directly at the entrypoint the runtime expects. For the nodejs image, the entrypoint is node already, so the CMD is just the script path.
The first time an on-call engineer tried kubectl exec -it pod -- sh against a distroless pod, they got exec failed: no such file or directory and pinged me in a panic thinking the container was corrupt. It wasn't. There's simply no shell to exec into. That's the tradeoff, and it's worth naming loudly to your team before it bites them at 2am.
Two ways out. During development, Google ships a :debug variant of each image that includes BusyBox:
FROM gcr.io/distroless/nodejs20-debian12:debug-nonroot
In production, we keep the lean image and attach an ephemeral debug container instead, which needs Kubernetes 1.25+:
kubectl debug -it checkout-7d9f --image=busybox:1.36 \
--target=checkout -- sh
That drops a BusyBox shell into the same process namespace as the running container, so you can inspect /proc, check open file descriptors, and poke the network without ever adding tooling to the production image.
Two things that come free in a normal base image are missing in distroless, and both fail in ways that look like application bugs.
If your app makes outbound HTTPS calls, it needs CA certificates. The nonroot and base distroless images include /etc/ssl/certs/ca-certificates.crt, but the static image does not. If you build a Go binary on distroless/static and it can't verify TLS, that's why. Copy the bundle from the build stage:
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
Timezones are the other one. There's no /usr/share/zoneinfo, so any code doing time.LoadLocation("America/New_York") returns an error. Either copy the tzdata in or, for Go, embed it with the time/tzdata build tag.
For compiled languages, Go and Rust especially, go straight to distroless/static or a scratch image and don't look back. The static binary carries everything. For Node, Python, and Java, distroless is still my default for anything customer-facing, because a 90% CVE reduction with a 3x size drop is not a hard sell to anyone who's sat through a compliance review. The one place I'd hesitate is a team with no container debugging discipline yet. If nobody knows kubectl debug exists, a shell-less image turns every incident into a longer incident. Teach that first, then switch the base image.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Users kept asking the same questions in slightly different words, and we paid full price every time. Semantic caching cut our LLM bill by a third.
A user got our support bot to recite its system prompt and then draft a refund it wasn't authorized to give. Two layers of guardrails, one on input, one on output, closed both holes.
Explore more articles in this category
We used to ship code and turn it on in the same breath, so every deploy was a bet. Feature flags split those two events apart and made rollbacks a config toggle.
Our best engineer quit citing on-call. We rebuilt the whole thing: saner rotations, runbooks that actually help at 3am, and escalation that doesn't punish asking for help.
Our early postmortems quietly assigned blame and taught people to hide mistakes. Here's the template and the facilitation rules that finally made them honest and useful.
Evergreen posts worth revisiting.