What distroless docker images contain and why teams use them
A distroless image contains only what your process needs at runtime: the language runtime (a JRE or the Node binary), CA certificates for outbound TLS, timezone data, and a minimal libc with its supporting libraries. It has no package manager, no shell and no coreutils. The final image is your application plus its runtime.
Teams adopt distroless for three reasons:
- Smaller attack surface. An attacker who gets code execution cannot spawn
sh, usecurlor install tools. - Fewer scanner findings. Every package you remove is one less source of CVEs to triage.
- Smaller container images. Smaller images pull faster on new nodes and during scale-out.
The benefit is largest for backend and platform teams running stateless APIs on an orchestrator. In that setup, nobody should be logging into a container. Logs, metrics and health checks are handled outside the process.
| Image type | Shell | Package manager | Debug tooling | Relative size |
|---|---|---|---|---|
| Full SDK | Yes | Yes | Extensive (compilers, network tools) | Largest |
| Slim runtime | Yes | Usually | Minimal | Medium |
| Distroless | No | No | None | Smallest |
The missing shell breaks several common Dockerfile habits:
RUNin the final stage fails. All installation and file preparation must happen in an earlier stage and be copied in.- Shell-form
ENTRYPOINTandCMDfail, because Docker wraps them in/bin/sh -c. Only exec form (a JSON array) works. - Variable expansion such as
$JAVA_OPTSin the command does not happen. That expansion was done by the shell. - Wrapper scripts like
entrypoint.shcannot run. Any setup they performed must move into the application or the orchestrator configuration.
How a multi-stage dockerfile works for distroless targets
Because the final image cannot run a shell, a package manager or a compiler, all of that work has to happen in an earlier stage. A multi-stage Dockerfile splits the build into a builder that has the full SDK and a runtime stage based on a distroless image. The runtime stage starts empty and receives only what is explicitly copied into it with COPY --from.
┌──────────────── builder (full SDK) ───────────────┐
│ 1. copy manifests + lockfiles │
│ 2. resolve dependencies <- cached layer │
│ 3. copy source │
│ 4. compile, test, prune dev dependencies │
└───────────────────────┬───────────────────────────┘
│ crosses the boundary:
│ app jar / dist/ + node_modules
▼
┌──────────── runtime (distroless) ─────────────────┐
│ language runtime + artifacts + config, no shell │
└───────────────────────────────────────────────────┘
The order inside the builder matters because of how layer caching works. If a layer's inputs change, that layer and every layer after it is rebuilt. Copy only the manifest and lockfile first, then resolve dependencies. With that order, a source-only edit reuses the dependency layer instead of downloading everything again.
You can also declare named stages such as test, debug and prod, and select one with --target. A debug stage can use a distroless image tag that includes a shell, while prod stays minimal. A test stage can run the suite without adding test tooling to the shipped image.
These cache benefits are reliable on a developer machine, where layers persist between builds. CI runners are often ephemeral and start with no cache, so every build is a cold build. To keep the cache in CI, export and import it explicitly with BuildKit's --cache-to and --cache-from options.
docker build --target <test|debug|prod>
┌─ deps (full SDK) ────────────────────────────────────────────┐
│ COPY manifests + lockfiles (pom.xml, package-lock.json) │
│ resolve dependencies <- reused if lockfile unchanged │
└──────────┬──────────────────────────────────┬────────────────┘
│ │
▼ ▼
┌─ test ──────────────┐ ┌─ builder (full SDK) ────────────┐
│ COPY source │ │ COPY source │
│ run the suite │ │ compile │
│ never shipped │ │ prune dev dependencies │
└─────────────────────┘ └────────────────┬─────────────────┘
COPY --from=builder │
app.jar | dist/ + prod node_modules ─┤
┌────────────────┴────────────┐
│ │
▼ ▼
┌─ debug ───────────────┐ ┌─ prod ────────────────┐
│ distroless :debug tag │ │ distroless runtime │
│ busybox shell │ │ artifacts + config │
│ for troubleshooting │ │ no shell │
└───────────────────────┘ └───────────────────────┘
Cache: layers persist between local builds; ephemeral CI runners
start cold unless cache export/import is configured.
A shared deps stage resolves dependencies from manifests and lockfiles. The test and builder stages each add source on top of it. Only the compiled artifacts and production dependencies cross into the distroless debug or prod target selected with --target.
Implementing it for a Java API
Start the build stage from a full JDK image and copy only the build descriptors first: pom.xml (or build.gradle and the wrapper files). Resolve dependencies in their own RUN step, for example with Maven's dependency:go-offline, so that layer stays cached until a dependency actually changes. Then copy src/ and run the package step. Editing application code now invalidates only the compile layer, not the dependency download.
A single fat JAR is the simplest artifact, but it puts every library and every class into one layer. Any code change therefore ships the whole thing again. An exploded or layered layout splits dependencies, snapshot dependencies, resources, and application classes into separate directories. You copy each into the runtime stage with its own COPY, so registries and nodes re-pull only the layer that changed. Spring Boot's layered jar mode produces this split directly. For other frameworks, unpacking the JAR and copying lib/ separately from classes/ gets the same effect.
In the final stage, use a Java distroless runtime image. Copy the artifacts with COPY --chown=nonroot:nonroot, and run as non-root through the :nonroot tag or a numeric USER. A numeric UID lets Kubernetes verify runAsNonRoot without having to resolve a username.
The final image has no shell, so $JAVA_OPTS never expands. You have two options:
- Write the JVM flags as literal arguments in an exec-form
ENTRYPOINT. - Set
JAVA_TOOL_OPTIONS, which the JVM reads by itself.
Before writing your own entrypoint, check the base image's default entrypoint so you don't duplicate java -jar.
APM agents follow the same pattern. Download or copy the agent JAR in the build stage, COPY it into the final image as a file, and reference it with -javaagent:.
Expect only modest size savings. The JRE makes up most of the image, and distroless mainly removes the OS tooling around it. A jlink custom runtime shrinks the image further, but you then have to maintain the module list and rebuild the runtime for every JDK patch yourself.
# ---- Build stage: full JDK + Maven ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
# Descriptor first so the dependency layer caches until pom.xml changes
COPY pom.xml .
RUN mvn -B dependency:go-offline
# Source changes only invalidate the layers below this line
COPY src/ src/
RUN mvn -B -o package -DskipTests \
&& mvn -B -o dependency:copy-dependencies -DoutputDirectory=target/lib
# APM agent is copied in as a plain file; no download in the runtime image
COPY agents/apm-agent.jar /opt/agent/apm-agent.jar
# ---- Runtime stage: distroless Java, non-root, no shell ----
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
# Separate COPYs: libraries rarely change, classes change on every commit
COPY --from=build --chown=nonroot:nonroot /src/target/lib/ /app/lib/
COPY --from=build --chown=nonroot:nonroot /opt/agent/ /app/agent/
COPY --from=build --chown=nonroot:nonroot /src/target/classes/ /app/classes/
# Numeric UID (nonroot = 65532) so Kubernetes can verify runAsNonRoot
USER 65532
# Read by the JVM itself; no shell expansion needed
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
# Base image defaults to java -jar, so replace it entirely.
# The JVM, not a shell, expands the lib/* classpath wildcard.
ENTRYPOINT ["/usr/bin/java", \
"-javaagent:/app/agent/apm-agent.jar", \
"-cp", "/app/classes:/app/lib/*", \
"com.example.api.Application"]
A multi-stage Dockerfile that caches Maven dependencies, ships libraries and classes as separate layers into a non-root distroless Java image, and passes JVM flags and an APM agent without a shell.
Implementing it for a Node API, including native addons
The Node build follows the same pattern. In the builder stage, copy package.json and the lockfile first and run npm ci. It installs exactly what the lockfile specifies and fails if the lockfile and manifest disagree, so every build resolves the same dependency tree. Copying the manifests before the source also keeps the install layer cached until dependencies actually change. Next, copy the source, run the TypeScript compile, then run npm prune --omit=dev so the compiler, type definitions and test tooling are removed before anything leaves the builder.
The final stage starts from a Node distroless image, ideally its non-root variant, and copies only the pruned node_modules, package.json and the compiled output, owned by the non-root user. The base image's entrypoint is already node, so CMD holds only the script path, such as dist/server.js. If you write CMD ["node", "dist/server.js"], Node will try to load a file named node.
Native addons and libc
Native addons such as bcrypt or sharp compile against the builder's C library. Distroless bases use glibc, so an addon built on an Alpine (musl) builder will not load in them. The same applies to Java JNI libraries. Use a Debian-based builder with the same CPU architecture as the target.
These mismatches don't fail the build. They fail at startup, or later when the module is first required. Add a smoke test to CI: run the built image with a -e argument that requires each native module (for example, docker run --rm <image> -e "require('bcrypt')"), or start the container and poll its health endpoint. Fail the pipeline on a non-zero exit.
# Builder: Debian 12 (glibc), matching the distroless base below. Not Alpine/musl.
# The full image includes python3/make/g++ in case a native addon compiles from source.
FROM node:22-bookworm AS build
WORKDIR /app
# Manifests first, so this layer stays cached until dependencies change.
# npm ci installs exactly what the lockfile specifies and fails on drift.
COPY package.json package-lock.json ./
RUN npm ci
# Compile TypeScript, then drop devDependencies (tsc, @types, test tools).
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
# Runtime: distroless Node on Debian 12, non-root variant (uid 65532).
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build --chown=65532:65532 /app/package.json ./
COPY --from=build --chown=65532:65532 /app/node_modules ./node_modules
COPY --from=build --chown=65532:65532 /app/dist ./dist
USER 65532
EXPOSE 3000
# ENTRYPOINT is already node, so CMD is only the script path.
# CMD ["node", "dist/server.js"] would make node look for a file named "node".
CMD ["dist/server.js"]
# CI smoke test, run after building (fails on non-zero exit):
# docker build -t my-api:ci .
# docker run --rm my-api:ci -e "require('bcrypt'); require('sharp')"
A multi-stage Dockerfile that runs npm ci, compiles TypeScript and prunes dev dependencies on a glibc builder. It copies the result into a non-root Node distroless image whose CMD holds only the script path, and ends with a smoke test that requires each native module.
Running in production: signals, health checks and debugging without a shell
Signals and PID 1
Distroless images have no shell, so a shell-form CMD fails outright: /bin/sh is missing. Use exec form, such as ENTRYPOINT ["java", "-jar", "/app/app.jar"] or CMD ["dist/server.js"] on a Node base whose entrypoint is already node. The runtime then becomes PID 1 and receives SIGTERM directly. PID 1 gets no default signal handling from the kernel, so the process has to install its own handlers. The JVM does this, and its shutdown hooks will run. Node does not, so register process.on('SIGTERM', ...) to stop accepting connections and drain in-flight requests. Without a handler, the container sits idle until the grace period ends and is then killed. If you need zombie reaping, use the init your platform provides, such as docker run --init, rather than adding one to the image.
Health checks
A HEALTHCHECK that calls curl has nothing to run, because curl is not in the image, and Kubernetes ignores HEALTHCHECK anyway. Expose /healthz and /ready endpoints and configure httpGet liveness and readiness probes. The kubelet makes those requests from outside the container, so the image needs no extra binaries.
Debugging without a shell
Have these options ready before the first incident:
- Debug image variants. Distroless publishes
:debugtags that include a busybox shell. Use them in staging only. - Ephemeral containers.
kubectl debug -it <pod> --image=busybox --target=<container>attaches a toolbox container that shares the target's process namespace. You can inspect/proc/1without restarting the pod. - A
debugbuild target. Add a stage that copies the same artifacts onto the:debugbase, then rundocker build --target debug -t api:debug .followed bydocker run -it --entrypoint sh api:debugfor local troubleshooting.
CI should only ever push the production target. A shell that ships to production "temporarily" tends to stay there.
# Build stage: full toolchain, never shipped.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# Debug target: same artifacts on the :debug base (adds a busybox shell).
# For local troubleshooting and staging only, never pushed as the prod tag.
# docker build --target debug -t api:debug .
# docker run -it --entrypoint sh api:debug
FROM gcr.io/distroless/nodejs20-debian12:debug AS debug
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["dist/server.js"]
# Production target: last stage, so a plain `docker build .` produces it.
# No shell. The base entrypoint is node, so exec-form CMD makes node PID 1
# and SIGTERM reaches the process.on('SIGTERM') handler in server.js.
# docker build -t api:prod .
# docker run --init -p 8080:8080 api:prod # --init only if you need zombie reaping
# In-cluster, attach tools instead of baking them in:
# kubectl debug -it api-7d9f6c-abcde --image=busybox --target=api
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 8080
# No HEALTHCHECK: there is no curl here. Use httpGet probes on /healthz and /ready.
CMD ["dist/server.js"]
A multi-stage Dockerfile with a shell-free production target as the default and a separate debug target on the distroless :debug base, plus the build, run and kubectl debug commands for each case.
Verifying, pinning and keeping images updated
Build the old and new images side by side and tag them distinctly, for example api:debian and api:distroless. docker image ls gives the headline size. docker history api:distroless shows the size of each layer, which tells you where the weight actually sits. Often it is the JRE or the node_modules copy, not the base.
Then run the same vulnerability scanner against both tags with the same database and severity threshold. Different scanners, or the same scanner on different days, produce numbers you cannot compare.
Illustrative estimate. Assume a Node API whose old image is built on a full Debian-based Node image, with 180 MB of node_modules. Moving to a distroless Node base might cut total size by roughly a third to a half. It might also remove most OS-package findings, because packages such as apt, bash and perl are no longer present. These figures are assumptions, not measurements. Your own numbers depend entirely on your dependencies.
Fewer findings does not mean zero risk. The scanner still reports CVEs in the runtime (the JVM or Node itself), in your JARs and in node_modules. Exploitable bugs in your own code never show up in that report at all. Keep auditing dependencies at build time.
Pin the base by digest (image@sha256:...) rather than by tag, so every build is reproducible. Pair that with an automated job that opens a pull request whenever the upstream digest changes, and let CI rebuild and rescan the image.
Distroless does not patch the runtime for you. A pinned digest freezes the vulnerabilities in that runtime until you bump it.
When to avoid distroless, alternatives, and impact on cost and engineering time
Distroless is a poor fit in a few predictable situations. If your team has no ephemeral-debug workflow, such as attaching a debug container or copying files out of a stopped container, then losing the shell turns routine incidents into guesswork. Images that depend on entrypoint scripts for templating config, waiting on dependencies or adjusting permissions will need that logic moved somewhere else first. Tooling that installs packages at startup, such as some APM agents, will fail outright. And if you need a specific Java distribution or Node major version that the published tags do not offer, you are stuck with what upstream ships.
| Option | Size | Debuggability | Compatibility | Maintenance |
|---|---|---|---|---|
| Slim runtime image | Moderate | Shell and package manager available | glibc; broad | Low; follow upstream tags |
| Alpine-based image | Small | Shell available | musl can break native addons and some JVM tooling | Low to moderate |
| jlink custom runtime | Small for Java | None unless you add it | Only the modules you include | Module list must track code changes |
| Self-built minimal base | Smallest you can manage | Your choice | Exactly what you ship | High; you own patching |
The engineering cost comes at the start. Dockerfiles get more complex, and shell logic has to move into application code, init containers or orchestrator config such as environment variables, mounted config and readiness probes. The savings build up over time. Fewer packages produce fewer scanner findings to triage. Smaller layers pull faster on cold nodes. Once one service works, its multi-stage template carries over to the next one with little change. With many services, the template is where most of the savings come from. With one or two services that rarely ship, a slim image may be the better trade.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to distroless docker images, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.