Best practices

Migrating to Distroless Images: A Step-by-Step Guide

Published July 19, 2026 · 10 min read

Migrating to distroless images removes the shell, package manager, and most of the operating system that a scan report blames for the bulk of your findings. This guide walks through the migration per language, what breaks along the way, and how to debug a container that no longer has a shell to exec into.

Migrating to distroless Docker images

What you give up, and what you gain

Distroless images strip out everything that is not the language runtime and your application: no shell, no package manager, no coreutils, often no root user by default. That is precisely what makes them attractive for a scan report — there is very little installed software left to carry a CVE — and precisely what makes the migration require some care, since tooling that assumed a shell was always available stops working.

Compare this to the Alpine and Debian tiers: Alpine still has a shell and a package manager, just a smaller one. Distroless has neither. Treat the migration below as a checklist, not a single flag flip.

What the toolchain costs you — ScanRook v1.14.2, 2026-07-04

Horizontal bar chart of total ScanRook findings per image: busybox 1.37 has 2, alpine 3.20 301, node 22-alpine 306, golang 1.23-alpine 379, golang 1.23 18,152, node 22 30,726CriticalHighMediumLowNo severity assignedbusybox:1.372 total · 0 criticalalpine:3.20301 total · 20 criticalnode:22-alpine306 total · 23 criticalgolang:1.23-alpine379 total · 23 criticalgolang:1.2318,152 total · 568 criticalnode:2230,726 total · 1,794 critical
The gap that motivates distroless: node:22 reports 30,726 total findings (1,794 critical) while node:22-alpine reports 306 (23 critical); golang:1.23 reports 18,152 (568 critical) against 379 (23 critical) for golang:1.23-alpine. busybox:1.37 — not a distroless image, but the closest thing in this data set to a near-empty runtime — reports 2. Almost all of that surface is OS userland and build tooling that the runtime never executes, which is exactly what a distroless final stage drops. Note the floor: alpine:3.20 by itself still reports 301, so going minimal shrinks the surface by orders of magnitude without ever reaching zero. Bar length is linear in total findings, so the smallest bars are only a few pixels wide — exact totals are printed at right. The four rated buckets do not always add up to the total because some advisories carry no CVSS severity; that remainder is the unfilled part of each bar. The busybox:1.37 scan was partial — its runtime package inventory was unavailable, so matching fell back to heuristics. busybox genuinely is minimal, but treat 2 as a floor rather than a verified complete count.

Step 1: Audit what your current image actually uses a shell for

Before switching, find every place your Dockerfile or runtime relies on a shell: CMD in shell form, HEALTHCHECK, entrypoint wrapper scripts, and any docker exec sh debugging habits your team relies on:

# Shell-form CMD — will break under distroless, no /bin/sh
CMD npm start

# Exec-form CMD — works fine under distroless
CMD ["node", "dist/index.js"]

# grep the Dockerfile for shell dependencies
grep -nE "HEALTHCHECK|CMD [a-z]|ENTRYPOINT [a-z]" Dockerfile

Anything using shell-form instructions needs to move to exec-form JSON array syntax before the migration, regardless of which final base you land on.

Step 2: Migrate a compiled Go or Rust service

Static binaries are the easiest migration — they have no runtime dependency on libc at all, so the static distroless base is sufficient:

FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

CGO_ENABLED=0 is the important flag — without it, Go links against glibc dynamically and the static base will fail at container start with a missing-interpreter error. Rust follows the same pattern by compiling against the musl target.

Step 3: Migrate a Node.js service

Node needs its runtime, so use the language-specific distroless variant rather than the static base, and make sure every CMD is exec-form:

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
CMD ["dist/index.js"]

Note the distroless Node images set the entrypoint to the node binary already, so CMD only needs the script path, not the full node dist/index.js invocation.

Step 4: Migrate a Python service

Python native extensions still need a compiler, so build wheels first and install them in the distroless stage where there is no pip available at runtime to do it later:

FROM python:3.11-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --target=/deps -r requirements.txt

FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY --from=build /deps /usr/lib/python3.11/site-packages
COPY . .
CMD ["app.py"]

Verify the target site-packages path matches the Python version baked into the distroless image tag exactly — a mismatch here is the most common distroless-Python migration failure.

Step 5: Replace HEALTHCHECK and debug workflows

Shell-based health checks and docker exec sh debugging both assume a shell that no longer exists. Move health checks to your orchestrator, and use an ephemeral debug container for troubleshooting:

# Kubernetes: probe the app's own HTTP endpoint, no shell required
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080

# Attach a debug shell without modifying the distroless image
kubectl debug -it mypod --image=busybox:1.36 --target=app

kubectl debug attaches a temporary container sharing the target's process namespace, which gives you a shell to inspect the running process without ever putting one in the shipped image.

Verifying the migration

Confirm the container actually starts and serves traffic, then confirm the scan reflects the smaller package set:

docker run -d -p 8080:8080 --name migration-check myapp:distroless
curl -f http://localhost:8080/healthz

docker save myapp:before -o before.tar
docker save myapp:distroless -o after.tar
scanrook scan --file before.tar --format json --out before.json
scanrook scan --file after.tar --format json --out after.json
jq '.summary' before.json after.json

Expect the finding count to drop sharply since the shell, coreutils, and package manager — and their entire advisory histories — are simply absent from the image. Whatever remains should be scoped to your language runtime and application dependencies.

Where ScanRook fits

A distroless migration is easy to verify with a scan, harder to verify by eye. ScanRook reads the packages genuinely present in each image layer, so you can confirm the shell and package manager are truly gone rather than just assuming the base image tag implies it. Pair this migration with our CVE reduction guide or check the docs for scan automation.

Frequently asked questions

What is a distroless Docker image?

An image with only the language runtime and app dependencies — no shell, no package manager, no coreutils.

How do I debug a distroless container without a shell?

Attach an ephemeral debug container with kubectl debug or docker debug, which shares the target's namespaces.

Can I run HEALTHCHECK in a distroless image?

Not directly — move health checks to your orchestrator's probes or an exec-form command written in your app's own runtime.

Does distroless work for Python and interpreted languages?

Yes, via language-specific images like python3-debian12, though native extensions still need compiling in a build stage first.

Confirm the migration with ScanRook

Scan your image before and after the distroless migration and see exactly which packages — and findings — disappeared.

Related Posts

More on this topic.