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.
What distroless actually strips out
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.
- No shell binary such as bash or sh, so there is no interactive entry point for an attacker who gains code execution inside the container
- No package manager like apt or yum, which removes an entire class of supply chain and privilege escalation tricks
- No standard coreutils, so common post exploitation commands simply do not exist on the filesystem
- The language runtime plus its shared libraries and CA certificates are kept, since the app genuinely needs them to run and to make outbound TLS calls
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.
Building with a multi-stage Dockerfile
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.
Real size numbers, side by side
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.
Debugging a container with no shell
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.
- Use the `:debug` tagged variant of the same distroless image during development, which bundles a busybox shell you can launch with an explicit entrypoint override, then swap back to the non debug tag for the real deploy
- Use `docker debug` from Docker's own CLI tooling, which attaches an interactive shell with prebuilt utilities like vim and curl to a running container or a static image without ever modifying the image itself
- Reproduce the failure locally against the build stage of your Dockerfile, which still has a full shell, since most application bugs surface identically whether the final stage has bash or not
# 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.
Why the CVE surface actually shrinks
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.
- Removing the package manager also removes the ability for an attacker to install new tools mid attack, which meaningfully raises the cost of a post exploitation foothold
- Removing the shell removes the most common way an attacker pivots from a code execution bug into interactive access
- A smaller package count means fewer packages to patch and re-scan every time a base image update ships, which reduces the ongoing maintenance burden as much as the initial attack surface
When distroless is not the right call
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.

