Hardening from the Ground Up: Using Distroless Base Images

Sep 17, 2026 min read

You just wired Trivy into your pipeline. First real scan, on a Node.js API that does nothing more exotic than call three internal REST endpoints and write to Postgres. The output comes back: 43 CVEs. You brace for the worst and start reading through them, and here’s the thing — your package.json has eleven dependencies, and none of them show up in the list. Every single finding traces back to bash, tar, perl, openssl-libs, and a dozen other packages that shipped with node:18 because that’s what full OS base images do. Your application never imports any of them. It never will. But Trivy doesn’t know that, and neither does the attacker who lands a shell in your container — they’ll happily use curl to pull a second-stage payload, because you handed it to them for free.

That’s the tax nobody mentions when they pick FROM node:18 out of habit: you’re not shipping an application, you’re shipping an entire Linux distribution with an application bolted onto the side. Every package manager, shell, and utility in that base image is either something an attacker can repurpose or something a scanner will flag — often both. The fix isn’t a smarter scanner. It’s deleting the OS.

This article walks through what a distroless base image actually contains, how it’s different from reaching for alpine, and what happens to your image size and vulnerability count when you make the switch. By the end you’ll have a working multi-stage Dockerfile that takes a standard Express app from a bloated node:18 image down to a distroless runtime with close to nothing left to exploit.

The Problem with Standard Base Images

ubuntu, debian, and their language-specific variants like node:18 exist to be general-purpose. They ship apt or apk so you can install anything later. They ship bash or sh so you can script against the container. They ship curl, wget, tar, and a long tail of coreutils because someone, somewhere, might need them during a debugging session at 2 a.m.

None of that is free. Security researchers call this class of tooling “living-off-the-land binaries” — the legitimate utilities already sitting in your container that an attacker uses to pivot, exfiltrate data, or pull down malware without ever needing to install anything of their own. A shell to execute commands. curl or wget to fetch a payload. A package manager to grab more tools if the built-in set isn’t enough. Your Node.js app doesn’t use any of these binaries in the course of doing its job — but an attacker who compromises it absolutely will, and they’re already sitting inside the perimeter you spent so much effort building.

The scanner findings and the attack surface are two sides of the same problem. Run Trivy against node:18 and you’ll typically see a few hundred vulnerabilities, the overwhelming majority of them OS-level packages your application never touches — perl, openssl, libtiff, whatever Debian shipped in that release. Switch to node:18-alpine and the count drops sharply, often into the tens, because Alpine’s package set is smaller and musl-based. But Alpine is still a full Linux distribution. It still ships apk, it still ships /bin/sh, and an attacker with shell access can still use both to move around your container. Alpine trims the attack surface. Distroless removes it.

What Is Distroless?

The open-source distroless project, originally created and still hosted by Google Container Tools, builds base images that contain your application, its language runtime, and the minimal set of OS libraries that runtime needs to execute — and stops there. No apt, no apk. No bash, no sh, no /bin full of coreutils. No package manager at all, because a package manager implies a way to add software after the fact, and the entire point of distroless is that nothing gets added after the fact.

That last part is the design decision that matters most. A shell isn’t a security hole by itself — it’s a tool. But it’s the specific tool an attacker needs to turn “I found a vulnerability in your dependency” into “I have a foothold in your container.” Remove the shell, and remote code execution in your app no longer hands the attacker an interactive environment. They land a process, not a terminal. There’s nothing to exec into, nothing to pipe commands through, no wget waiting to fetch stage two. This is why the pillar article in this series, The Ultimate Guide to Shift-Left Security for Docker Containers, treats distroless as the highest-leverage single change you can make to a running container — it doesn’t just fix known CVEs, it closes off the technique attackers use to exploit the next one.

Distroless images are built per-language: gcr.io/distroless/nodejs22-debian12 bundles the Node.js runtime and its shared libraries against a Debian 12 base with everything else stripped out; there are equivalents for Java, Python, and Go (Go binaries, being statically compiled, can even run on distroless/static, which is smaller still). Chainguard has since entered the same space with its own zero-CVE-focused images and a faster patch cadence, worth a look once you’ve got the distroless pattern down — but Google’s images remain the reference implementation and the one most teams reach for first.

One honest caveat: distroless doesn’t help you with dependencies you actually use. If your node_modules tree has a vulnerable package, it’s vulnerable whether the base image is Ubuntu or distroless. What distroless kills is the noise — the CVEs and the attack surface that came along for the ride and were never yours to begin with.

Practical Example: Refactoring to Distroless

Here’s the scenario: a standard Express.js application running on node:18, currently deployed with a single-stage Dockerfile that installs dependencies and runs the app in the same image it ships. You want to keep the developer experience of a normal Node environment for the install step, but ship something with nothing extra in production.

You can’t run npm ci inside distroless — there’s no npm, no node binary outside the runtime distroless already provides, and definitely no package manager to go fetch one. So the build has to happen somewhere else and hand off only the finished artifact. That’s what multi-stage builds exist for: one FROM for building, a second FROM for running, and a COPY --from between them so the final image never sees the tools used to assemble it.

# ---- Stage 1: Build ----
# Use a full Node image here on purpose — this stage needs npm,
# and possibly native build tools for compiled dependencies.
# None of this ships in the final image.
FROM node:22 AS builder
WORKDIR /app

# Copy only the manifest files first so Docker can cache this layer
# and skip npm ci on rebuilds where dependencies haven't changed.
COPY package*.json ./
RUN npm ci --omit=dev

# Now bring in the application source and any build step (TypeScript, etc.)
COPY . .
# RUN npm run build   # uncomment if you compile TS or bundle assets

# ---- Stage 2: Runtime ----
# distroless/nodejs22-debian12 contains only the Node.js runtime and the
# shared libraries it depends on. No shell, no package manager, no apt.
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app

# Pull in exactly what the build stage produced — compiled code and
# node_modules — and nothing else. The builder image is discarded.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/src ./src

# Distroless nodejs images set the entrypoint to `node` already,
# so CMD only needs to name the entry file.
CMD ["src/index.js"]

The builder stage never makes it into the shipped image — Docker discards everything except what an explicit COPY --from=builder pulls forward. Build the image, and the size difference is immediate (according to Docker Hub uncompressed layer metrics): node:18 on its own runs upward of 900 MB before your code even lands in it, node:18-alpine typically lands somewhere in the 170-180 MB range, and the distroless runtime stage usually comes in a little lighter than Alpine — no shell, no package manager, and no coreutils to carry. Point Trivy at it and the OS-level findings that used to number in the dozens or hundreds collapse to zero, because there’s effectively no general-purpose OS left to scan. Whatever findings remain will be in your own node_modules, which is exactly where you want the scanner’s attention focused.

Multi-Stage Distroless Build

This diagram shows how a multi-stage Dockerfile isolates build tools from the final production runtime.

flowchart LR subgraph Stage 1: Builder direction TB A[node:22 Base Image] --> B[npm ci] B --> C[Compile/Bundle Code] C --> D[Node Modules + Source] end subgraph Stage 2: Runtime direction TB E[distroless/nodejs22] --> F[COPY --from=builder] F --> G[Final Minimal Image] end D -->|Only App Artifacts| F %% Note that stage 1 is discarded Stage 1 -.Discarded.-> H((/dev/null))

Visual Notes:

  • The final image never inherits the shell, package manager, or build tools present in Stage 1.
  • The COPY command acts as the strict boundary between the build environment and production environment.

Troubleshooting Common Issues

“I can’t docker exec -it <container> /bin/sh into my container.”

Correct — there’s no shell to exec into. This is the feature working as intended, not a bug to route around. Two options: run the :debug variant of the distroless image (gcr.io/distroless/nodejs22-debian12:debug) during local development, which bundles a busybox shell for exactly this purpose, then switch back to the standard tag before you ship. Or, in Kubernetes, use an ephemeral debug container — kubectl debug -it <pod> --image=busybox --target=<container> attaches a throwaway shell alongside your running pod without touching the production image at all.

"RUN apt-get install (or apk add) fails in my Dockerfile."

There’s no package manager in the distroless stage, and there never will be — that’s the whole point. If you need a package installed, it belongs in the builder stage, compiled or vendored into the artifact you copy forward. Anything that has to exist at runtime has to already be in the copied files.

“My binary fails with exec format error or a missing-library error.”

This usually means something you copied into the final image was dynamically linked against a library distroless doesn’t ship — most often glibc, if your build stage used a musl-based image like Alpine while your runtime stage is Debian-based, or vice versa. Match your builder’s C library to your runtime’s, or statically compile the binary if the language supports it (Go handles this cleanly with CGO_ENABLED=0). Distroless works well for Node, Go, Java, and Python; applications that lean hard on system-level OS interaction — cron, custom init scripts, ad hoc shell tooling — are a worse fit and may need more restructuring than a base image swap can give you.

Best Practices and Tips

Do:

  • Treat multi-stage builds as mandatory, not optional — there’s no other way to get a compiled artifact into an image with no package manager.
  • Use the :debug tag locally and during CI debugging sessions; pin the standard tag (or a specific digest) for what actually deploys.
  • Pin your distroless base image by digest, not just tag, the same way you’d pin any other production dependency.

Don’t:

  • Don’t try to apt-get, apk add, or pip install anything inside the distroless stage. It will fail, and that failure is the image doing its job.
  • Don’t assume a clean Trivy scan on the base image means a clean scan overall — your dependencies still need the same scanning-in-CI discipline this series covers under Trivy in GitHub Actions pipelines, and the alerts it generates still need real triage, which is its own problem covered in the article on cutting through scanner alert fatigue.

Smaller images have a practical upside beyond security, too: less to pull means faster cold starts and quicker horizontal autoscaling, since your orchestrator spends less time waiting on a docker pull before a new replica is ready to take traffic.

Conclusion

Cutting a Docker image down to distroless doesn’t patch a vulnerability — it removes the category of vulnerability entirely, along with the shell and the package manager an attacker would have used to make anything else in the container do their bidding. If it’s not in the image, nobody can exploit it, misuse it, or scan it into a report you have to triage on a Friday afternoon.

Three things worth carrying forward:

  1. General-purpose base images bundle tools your application doesn’t need and attackers do — that gap is pure risk with no corresponding benefit to you.
  2. Distroless strips the shell and package manager specifically, which doesn’t just reduce CVE count, it removes the mechanism attackers use to escalate a single bug into a foothold.
  3. Multi-stage builds are the only way to get there — build with a full-featured image, ship with nothing but the compiled result.

Pick one non-critical service, swap its base image for a distroless equivalent, and run the same Trivy scan you ran last week. The delta will make the case better than this article can. And if you haven’t already locked down the earlier stages of the pipeline — pre-commit hooks catching secrets before they’re committed, Checkov linting your Terraform before it applies bad IAM — this fits as the layer that hardens what actually ships, which is the throughline for the rest of The Ultimate Guide to Shift-Left Security for Docker Containers.

Sources