Most of your image is dead weight.
Somewhere in your cluster right now, one container is running a full Debian operating system — package manager, shell, compilers, the whole furniture showroom — only to serve one small API. Nobody chose this. It just happened, one convenient FROM line at a time, until the thing shipping to production carried far more software than the app it exists to run.
Distroless images fix exactly this. They are base images with the operating system taken out: your app, the runtime libraries it needs, and CA certificates (the ID cards your app uses to trust secure websites) — and nothing else. This post is the practical path off bloated base images: why it matters, the multi-stage Dockerfile pattern that makes it painless, how runtime upgrades get easier, and the answer to the question everyone asks first — how do you debug a container that has no shell?
Why your base image is the problem
Three costs, and all three keep compounding. First, attack surface — meaning, simply, how much stuff an attacker can play with once they get inside. Every installed package is code within their reach. A shell plus a package manager inside a compromised container turns a small foothold into a full toolkit: download tools, compile tools, jump to the next machine. Remove both, and the same foothold buys far less.
Second, CVE scanner noise. A CVE is a published security flaw with an ID number, and scanners flag one for every vulnerable package in your image — including packages your app never even loads. Every finding then needs a human decision: is it reachable, does it matter, who fixes it? Do this for a few hundred findings and your team learns to skim the report. That skimming is exactly when a real, dangerous finding hides inside the noise. Fewer packages means fewer findings — and the ones left are actually worth reading.
Third, pull time. A bigger image pulls slower on every node, every deploy, every scale-up. The image your app truly needs is measured in tens of megabytes. The operating system wrapped around it multiplies that several times over — for zero benefit while the app runs.
Before: full Debian userland, package manager, shell, and dev tools stacked under the app. After: app, runtime libs, and CA certs only.
Every package in your image is a CVE waiting for a scanner to find it — including the ones your app never loads.
The multi-stage pattern: build fat, ship thin
The trick that makes distroless painless is the multi-stage Dockerfile. Think of it like a restaurant kitchen and a serving plate. Stage one is the kitchen: use the fattest image you like — full toolchain, shell, test runners, everything. Stage two is the plate: it starts FROM a distroless image and copies in only the finished dish — the built artifact. The kitchen never leaves the CI runner (the machine that builds your code). Only dinner reaches the customer.
Before — the convenient default. One stage, and the whole kitchen ships to production:
# Before: everything ships, including the kitchen sink
FROM node:22-bookworm
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "dist/server.js"]
After — build in the fat image, run in distroless:
# ---- build stage: fat is fine here, it never ships ----
FROM node:22-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
# ---- runtime stage: only what the app needs ----
FROM gcr.io/distroless/nodejs22-debian13:nonroot
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
CMD ["dist/server.js"]
Three details trip people up the first time, so go slow here. The :nonroot tag runs your app as UID 65532 — a user ID with no special powers — so the --chown=nonroot:nonroot on the copy keeps file ownership consistent, and your app must listen on an unprivileged port (above 1024; low ports are reserved for the system). Second, the language version lives inside the image name (nodejs22), which makes runtime upgrades a one-line change — more on that below. Third, the -debian13 suffix pins the underlying Debian release openly. Pin it. The un-suffixed name will one day roll forward to a newer Debian and quietly change your base underneath you.
Multi-stage build pipeline: source to build stage to distroless runtime stage to registry to cluster.
The build stage can be as fat as it wants — it never leaves the CI runner. Only the compiled artifact crosses into the runtime stage.
“But how do I debug without a shell?”
This one objection kills most distroless migrations before they start, and it has two good answers. First, the :debug variants. Every distroless image ships a debug twin with a busybox shell included — gcr.io/distroless/nodejs22-debian13:debug. Already on a tag like :nonroot? The convention is debug- plus your tag, e.g. :debug-nonroot. Build the debug variant for local troubleshooting. Just never ship it.
Second — and this is the one that matters in production — kubectl debug. Normally you would exec a shell into the app container, but on distroless that fails by design; there is no shell to open. So instead, you inject an ephemeral container — a temporary helper container that lives only for this debugging session — into the running pod:
# inject a busybox shell into the running pod; the pod spec never changes
kubectl debug -it my-pod --image=busybox --target=app
# inside the ephemeral container:
/ # ps aux | grep node
/ # cat /proc/1/environ | tr '\0' '\n'
Here is what makes it work. The ephemeral container shares the pod’s network namespace (its private network space) by default, and --target also shares the target container’s process namespace — so from the helper, you can inspect the running app’s processes, file descriptors, and environment variables, almost as if you were inside it. For heavy network debugging, swap busybox for nicolaka/netshoot, which bundles tcpdump, curl, dig, and friends. One more trick worth keeping in your pocket: /proc/1/root inside the debug container is the distroless container’s filesystem, so you can read its files without a shell ever existing inside it.
kubectl debug injects an ephemeral busybox container into the pod, sharing network and process namespaces with the distroless app container.
kubectl exec is a habit. kubectl debug is the replacement — the shell arrives with the debugger, not with the workload.
Upgrading runtimes without drama
Here is the part teams underestimate: distroless makes language upgrades easier, not harder. The runtime version sits right in the image name — nodejs20 to nodejs22, java21 to java25 — so an upgrade is a one-line FROM change in the Dockerfile, reviewed like any other diff. No more archaeology: “which base image were we on, and what else came bundled with it?”
A calm way to roll it out (illustrative): build the new image and run the service’s existing test suite against it in CI first. Promote one low-risk service, watch it through a normal deploy cycle, then move the fleet service by service. Keep the :debug variant tagged during the migration, so anyone troubleshooting has a shell one image swap away. And pin the -debian13 suffix everywhere, so a future Debian rollover can’t change your base silently in the middle of the migration.
What does not change: your app’s files land in the same layout, environment variables and secrets mount the same way, and health checks behave identically. The app notices a new runtime version — which you would be testing anyway — not a whole new world.
The migration checklist
Work through this per service, in order. Most items are one-time Dockerfile edits; the last three are habits:
[] Pick the right base:
staticfor self-contained binaries,basefor glibc-linked apps, language images (nodejs22,python3,java21) for the rest[] Pin the Debian suffix explicitly (
-debian13) — don’t let the next Debian release surprise you[] Go multi-stage: build in the fat image,
COPY --from=buildonly artifacts into distroless[] Run as nonroot (
:nonroottag); listen on ports above 1024[] Verify TLS works — distroless ships CA certificates, but confirm in staging, not production
[] Check for shell dependencies: startup scripts,
sh -cwrappers, and exec-form vs shell-formCMD— convert to exec form[] Retrain the muscle memory:
kubectl debug --image=busybox --target=instead ofkubectl exec[] Keep a
:debug-tagged variant available during the migration window[] Scan the new image and compare scanner noise against the old one — the delta is the point
[] Roll out one service first (illustrative canary), then the fleet
That shell-dependency check deserves one extra minute, because it is the only item where you must read your app, not just your Dockerfile. Anything that shells out while running — a startup script, a health check that pipes through sh, a CMD written as a plain string — needs converting before the migration, not during it. Find them on a calm Tuesday, not during a midnight incident.
The takeaway
Your containers do not need an operating system. They need your app, the libraries it loads, and certificates to talk TLS. Everything else in the image is habit — and habit is the most expensive dependency in the Dockerfile, paid in attack surface, scanner noise, and pull time on every single deploy.
Start with one service, the multi-stage pattern, and the checklist above. The first migration teaches you where the shell dependencies hide; after that, the rest are copy-paste. And the next time a scanner report lands with a fraction of the findings it used to have, you will know exactly which lines of the Dockerfile earned it.
© 2026 Honey Kiran. All rights reserved.
Honey Kiran · DevOps & Platform Engineering
I write about the unsexy work that keeps systems alive at 3am — monitoring, SRE, Terraform, and platform engineering.
New posts regularly. Follow along — and send this to the teammate whose Dockerfile still starts FROM ubuntu:latest.
Examples are illustrative — validate in your own environment before running in production.
Disclaimer
This post describes my personal experience with my own systems and accounts, in my own words. It is a first-person account of what I observed and what I do in my own work — not a statement of any company's intentions, policies, or internal decisions. Where I do not know something, the post says so.
I am not affiliated with, endorsed by, or speaking for any company, product, or vendor named in this post. All product and company names are trademarks of their respective owners and are mentioned only to describe the experience accurately.
Any error messages, documentation excerpts, or figures quoted here are reproduced from my own records or from the public sources linked in the post, believed accurate at the time of writing. Product behavior changes over time; verify against current official documentation before relying on any detail.
Nothing in this post is professional, financial, legal, or security advice. Examples are illustrative — validate in your own environment before running anything in production.
Corrections are welcome: if a company named here publishes an official statement, or a detail above is wrong, I will update this post and note the correction. The goal is an accurate public record of my experience, not a verdict on anyone.




Top comments (0)