Distroless Docker Images: Smaller, Safer Containers

Photo by fringedbenefit on flickr
A distroless image is a minimal container base that includes only your application and its runtime dependencies, such as the language runtime, shared libraries, and CA certificates. It deliberately excludes shells, package managers, and standard OS utilities, which is what makes it much smaller and lower in vulnerability count than a full Debian or Alpine base.
Alpine is already smaller than a full OS image, but it still ships a shell and package manager, and its musl libc can silently break native Node addons or Python wheels that expect glibc. Distroless glibc based images remove the shell and package manager entirely while keeping full glibc compatibility, which usually means fewer surprises for compiled dependencies.
Three practical options work well: use the debug tagged variant of the same distroless image during development, which bundles a busybox shell; use Docker's own docker debug command, which attaches an interactive toolbox to a running container or image without modifying it; or reproduce the issue locally against the build stage of your Dockerfile, which still has a full shell.
Google publishes official distroless variants for Node.js, Python, Java, and a static base for compiled languages like Go, and community maintained variants exist for others. If your app shells out to system binaries at runtime, such as calling ffmpeg or imagemagick from a child process, you need to vendor those binaries into the image explicitly since distroless will not have them by default.
Yes, because vulnerability scanners match installed package versions against CVE databases, and a distroless image simply has far fewer packages installed to match against. Removing the package manager and shell also closes off the most common ways an attacker escalates from code execution to interactive access inside a compromised container.

Photo by fringedbenefit on flickr
Every layer you ship in a production Docker image is attack surface. A full Debian or Ubuntu base drags in a shell, a package manager, coreutils, and dozens of libraries your application never touches, and every one of those becomes a line item the next time a vulnerability scanner runs against your registry.
Distroless images strip all of that away, keeping only the language runtime and the shared libraries your binary actually links against. Combined with a multi-stage Dockerfile, the result is a production image that is smaller, faster to pull, and dramatically quieter in CVE reports. This post covers how distroless works, how to build for it with multi-stage Dockerfiles, real size numbers, how to debug a container with no shell, and when it is not the right call.
Distroless images, maintained by Google's container tools project, are built around one idea: put only your application and its runtime dependencies in the final image. Everything a normal Linux distribution ships for interactive use is gone by design.
Google publishes distroless variants per language: nodejs, python3, java, and a static base for Go binaries. Pick the one that matches your runtime rather than trying to build your own from scratch on day one.
Multi-stage builds let a single Dockerfile use several FROM instructions, one per stage. The first stage gets the full toolchain: compilers, npm, dev dependencies, everything needed to produce build artifacts. The final stage starts fresh from a distroless base and copies in only the compiled output, using the COPY instruction with a from flag that points at an earlier stage by name.
# Stage 1: build the app with full Node.js tooling
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime — no shell, no package manager, no npm
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]Nothing from the build stage leaks into the final image unless you explicitly copy it. The SDK, the source TypeScript files, dev dependencies, and any temporary build artifacts stay behind in a stage that is discarded once the build finishes. Name your stages with an AS clause so the Dockerfile still makes sense if you reorder instructions later, and consider a target flag during local development so you can build and inspect just the build stage without waiting for the final copy step.
Numbers vary by app, but the pattern below holds consistently across Node.js services. These are approximate final image sizes for the same small API server built three different ways.
| Base image | Approx size | Notes |
|---|---|---|
| node:20 | ~1.1 GB | Full Debian, includes an entire build toolchain the running app never needs |
| node:20-alpine | ~180 MB | musl libc based, smaller but occasionally breaks native Node addons that expect glibc |
| gcr.io/distroless/nodejs20-debian12 | ~120 MB | glibc based, no shell or package manager, closest to the true runtime footprint |
The size drop matters for pull times on every deploy and every autoscaled pod start, but the bigger win is usually the vulnerability scan. Fewer packages means fewer CVEs a scanner like Trivy or Grype can even find, because there is nothing installed to match against a CVE database in the first place. These numbers also compound at scale: a fleet running a few hundred replicas across a cluster pulls that saved few hundred megabytes on every rollout and every autoscale event, which adds up to real bandwidth and startup latency savings over a month of normal deploy activity.
The first time `docker exec -it my-app bash` fails with an entrypoint not found error against a distroless container, it is disorienting. There is no bash in the image, and there never will be, that is the entire point. There are still three practical ways to debug it.
# Attach an interactive debug shell to a running distroless container
docker debug my-running-container
# Or inspect the image itself, before running it
docker debug gcr.io/distroless/nodejs20-debian12Changes made through a debug shell attached to a running container are never persisted back into the image. If you install a tool or edit a file to test a theory, capture what you learned in the Dockerfile itself, because the container reverts the moment the debug session ends.
Vulnerability scanners work by matching installed package versions against a known CVE database. A full OS base image carries hundreds of packages that were installed for general purpose use, most of which your app never executes but all of which still get flagged when a new CVE lands against, say, an old version of a text editor nobody launches. In practice this means a security team reviewing scan output spends less time triaging findings that have nothing to do with the actual application, and more time on the handful of CVEs that could realistically matter.
Distroless is not free. Teams new to it lose time the first week because familiar debugging habits like exec into bash stop working, and any runtime dependency your app shells out to at runtime, such as calling a system binary via a child process, has to be vendored in explicitly since it will not be there by default. If your team ships fast and debugs mostly through logs and application level health checks rather than live shelling into containers, the transition cost is small. If your deploy process depends heavily on jumping into a running container to poke around, budget time to adjust the workflow before rolling this out broadly.
A good rollout order: convert one low traffic service to a distroless multi-stage build first, confirm your logging and health checks give you enough signal without shell access, then roll the pattern out to the rest of the fleet.
Distroless plus multi-stage builds is one of the highest leverage changes you can make to a Docker based deploy pipeline. The size drop is nice, but the real payoff is a production image with almost nothing in it for an attacker to use once they are inside, and a vulnerability scan that reports on your actual runtime footprint instead of an entire general purpose Linux distribution you never needed in the first place.