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.

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
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=appkubectl 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.