How a distroless docker multi-stage build works

A multi-stage build is a single Dockerfile with more than one FROM instruction. Each FROM starts a new stage with its own filesystem. Earlier stages do the heavy work: installing dependencies with npm, compiling with Maven or Gradle, running tests, pruning dev dependencies. Only the last stage becomes the shipped image. Nothing from earlier stages carries over automatically. A file reaches the final image only if you copy it there with an explicit COPY --from=<stage>, such as a node_modules directory, a compiled dist folder, or a JAR. Compilers, build caches, test frameworks and source files stay behind in stages that are discarded.

A distroless base image is the destination for those artifacts. It contains a language runtime, either Node or a JVM, plus the minimal system files that runtime needs: a C library, CA certificates for TLS, timezone data, and basic user and group entries. It has no shell, no package manager and no coreutils such as ls, cat or wget.

Two practical consequences follow. First, RUN instructions in the final stage generally fail, because there is no /bin/sh to execute them. All preparation has to happen in a build stage. Second, ENTRYPOINT and CMD must use exec (JSON array) form, which calls the runtime directly.

The main reason to adopt this pattern is security. If an attacker gets code execution in your API, they find no shell to spawn, no curl to fetch a payload and no package manager to install tools. Fewer packages also means fewer CVEs for scanners to report and for you to triage. Smaller images are a real benefit, but a secondary one.

+------------------------------------------------------------+
| BUILD STAGE   FROM <full SDK image: node / maven / gradle> |
|   npm ci / mvn package / gradle build, run tests, prune dev|
|   left behind (discarded): compilers, npm, Maven/Gradle,   |
|   build caches, test frameworks, source files              |
+------------------------------------------------------------+
                 |
                 |  COPY --from=build  (explicit; nothing else carries over)
                 |  Node: node_modules + dist/    Java: app.jar
                 v
+------------------------------------------------------------+
| FINAL STAGE   FROM <distroless base: nodejs or java>       |
|   +----------------------------------------------------+   |
|   | copied artifacts (dist/, node_modules or app.jar)  |   |
|   +----------------------------------------------------+   |
|   | language runtime: Node or JVM                      |   |
|   +----------------------------------------------------+   |
|   | libc, CA certificates, tzdata, passwd/group        |   |
|   +----------------------------------------------------+   |
|   absent: shell, package manager, coreutils (ls, cat, wget)|
|   ENTRYPOINT/CMD in exec form: ["node", "dist/server.js"]  |
|   RUN here fails: no /bin/sh to execute it                 |
+------------------------------------------------------------+
                 |
                 v
   Shipped image: smaller attack and misuse surface (primary),
                  smaller image size (secondary)

Build tooling stays in the discarded build stage, and only artifacts copied with an explicit COPY --from land on a distroless base that holds the runtime and minimal system files but no shell, package manager or coreutils.

Why and when to use distroless for Node and Java APIs

Distroless suits backend and platform teams running stateless HTTP APIs on an orchestrator that already handles the operational work. If the platform probes an HTTP endpoint, collects logs from stdout and stderr, and replaces failed containers, the container needs only the runtime and your application. Nothing inside it has to run curl, rotate log files or start a cron job.

Removing the shell and package manager limits what both an attacker and a careless operator can do. An attacker who gets code execution cannot drop into sh, chain coreutils together or apt-get install a toolkit. They are left with whatever the Node or Java runtime itself allows, which is still a lot, so distroless reduces risk but does not replace patching. An operator cannot exec in and hot-fix a config file, so every change has to go back through the build pipeline.

Runtime image Shell Package manager Coreutils libc CA certificates
Full (Debian-based) Yes apt GNU glibc Yes
Slim Yes apt GNU glibc Varies
Alpine BusyBox sh apk BusyBox musl Usually
Distroless No No No glibc Yes

Keep size expectations modest. Distroless removes the OS layer, but the JRE and node_modules are often most of the image. Illustrative estimate: with 350 MB of dependencies and a 60 MB runtime, cutting 80 MB of OS packages shrinks the image by about 15%. The larger gains are in attack surface and in how many CVEs you have to triage.

Implementing a distroless Node build

The Dockerfile has two stages: a builder based on a full Node image, and a runtime based on the nonroot variant of the distroless Node.js image. Use the same Node major version in both so native modules compiled in the builder are ABI-compatible at runtime.

Builder stage

Copy only package.json and package-lock.json first, then run npm ci. Docker caches each layer by its inputs, so a change to application code does not invalidate the dependency layer. Only a lockfile change triggers a full reinstall. npm ci installs exactly what the lockfile specifies and fails if the two files disagree, so builds are reproducible.

Next, copy the source and run your build step, such as TypeScript compilation or bundling, which needs dev dependencies. Then run npm prune --omit=dev to remove the compilers, test frameworks and type definitions before anything reaches the runtime image.

Runtime stage

Copy node_modules, package.json and the compiled output from the builder. Add --chown=65532:65532 to each COPY. That is the UID of the nonroot user, and it covers any directory the app writes to at runtime, such as a cache or upload path. Use the numeric ID so ownership matches the image's user exactly.

The distroless Node image's entrypoint is already the node binary. Your CMD therefore lists only the script path in exec form, for example ["dist/server.js"]. A shell-form CMD would fail because there is no /bin/sh to parse it. Exec form also makes Node PID 1, so it receives SIGTERM directly.

Bind to a port above 1024, such as 3000 or 8080. A non-root process cannot bind privileged ports without extra capabilities.

# ---- Builder: full Node image with npm and build tooling ----
FROM node:22-bookworm AS builder
WORKDIR /app

# Manifests first so this layer stays cached until the lockfile changes
COPY package.json package-lock.json ./
RUN npm ci

# Source and build step (e.g. tsc), which needs dev dependencies
COPY . .
RUN npm run build

# Remove compilers, test frameworks and type definitions
RUN npm prune --omit=dev

# The runtime image has no shell, so create writable dirs here
RUN mkdir -p /app/uploads

# ---- Runtime: distroless Node, same major version, nonroot variant ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
ENV NODE_ENV=production PORT=3000

# 65532 is the nonroot user's UID/GID; numeric IDs match the image user exactly
COPY --from=builder --chown=65532:65532 /app/package.json ./
COPY --from=builder --chown=65532:65532 /app/node_modules ./node_modules
COPY --from=builder --chown=65532:65532 /app/dist ./dist
COPY --from=builder --chown=65532:65532 /app/uploads ./uploads

# Unprivileged port: no extra capabilities needed for a non-root process
EXPOSE 3000

# Entrypoint is already the node binary; exec form passes only the script.
# Node runs as PID 1 and receives SIGTERM directly.
CMD ["dist/server.js"]

A two-stage Dockerfile that caches dependency installs, prunes dev dependencies, and runs compiled output as the nonroot user on port 3000 in a distroless Node 22 image, with an exec-form CMD and a writable uploads directory owned by UID 65532.

Implementing a distroless Java build

The Java build follows the same split, but the builder stage needs a full JDK and your build tool. Copy pom.xml, or build.gradle, settings.gradle and the Gradle wrapper, before any source. Then resolve dependencies, for example with mvn dependency:go-offline. Because this layer depends only on the manifest, Docker reuses it until a dependency changes. A source edit then triggers only compilation, not a full re-download of the dependency tree. After that, copy src/ and package the jar, skipping tests if CI already runs them in a separate step.

The runtime stage uses a distroless Java image that ships a JRE only, in its nonroot variant. It contains no compiler, no build tool and no shell. Copy in just the built artifact.

For Spring Boot applications, consider extracting the jar into its layers first: dependencies, loader and application classes. Copy each layer separately so a code change invalidates only the small top layer and not the large dependency layer beneath it.

Set the entry point in exec form, such as ["java", "-jar", "/app/app.jar"], so the JVM runs as PID 1 and handles SIGTERM itself. Put static JVM options directly in that array. Use JAVA_TOOL_OPTIONS for options that operators should be able to override per environment, because the JVM reads that variable without needing a shell.

Many older images used an entrypoint.sh that calculated heap size from available memory and then called exec java. That approach cannot work here. Move the logic into deployment configuration instead. Container-aware flags like -XX:MaxRAMPercentage let the JVM size its heap from the container's memory limit. Any remaining logic belongs in the application itself.

# ---- Build stage: full JDK plus Maven ----
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /workspace

# Copy only the manifest first so the dependency layer stays cached
# until pom.xml changes.
COPY pom.xml .
RUN mvn -B dependency:go-offline

# Source edits invalidate the cache only from here down.
COPY src ./src
# Tests are skipped because CI runs them in a separate step.
# Assumes pom.xml sets <finalName>app</finalName>.
RUN mvn -B -DskipTests package

# ---- Runtime stage: JRE only, no shell, runs as non-root ----
FROM gcr.io/distroless/java17-debian12:nonroot
WORKDIR /app

# Copy just the built artifact. No JDK, Maven or sources.
COPY --from=build /workspace/target/app.jar /app/app.jar

# The JVM reads this variable directly, so no shell is needed.
# Operators can override it per environment, for example with
# docker run -e JAVA_TOOL_OPTIONS=... or in a Kubernetes env block.
# MaxRAMPercentage sizes the heap from the container memory limit,
# which replaces heap math that used to live in an entrypoint.sh script.
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0"

EXPOSE 8080

# Exec form makes the JVM PID 1, so it receives SIGTERM directly.
# Static JVM flags go in this array.
ENTRYPOINT ["java", "-XX:+ExitOnOutOfMemoryError", "-jar", "/app/app.jar"]

A multi-stage Maven Dockerfile that caches dependency resolution in its own layer and copies only the jar into a nonroot distroless JRE image. JVM options are set through an exec-form ENTRYPOINT and an overridable JAVA_TOOL_OPTIONS instead of a shell script.

Native dependencies, health checks and debugging without a shell

Native code is the first place the build and runtime stages can disagree. Distroless base images ship glibc, so a Node addon compiled in an Alpine (musl) build stage will not load. A glibc build stage is also unsafe if its glibc is newer than the runtime's, because the binary then requires symbol versions the runtime does not have. Node reports a failed require with a loader error that can look like a missing file. Java throws UnsatisfiedLinkError, often only when the JNI-backed code path is first used, which can be well after startup. Build on the same distribution family and release as the runtime image. If a library extracts its .so at runtime, make sure a writable temp directory exists.

HEALTHCHECK CMD curl ... fails because the image contains neither curl nor a shell to run it. You have three options:

  • Use orchestrator HTTP probes, which run outside the container.
  • Add a health-check entry point to the app and invoke it in exec form with the runtime binary.
  • Copy a small static probe binary into the image.

For debugging, attach an ephemeral container that shares the process namespace, such as kubectl debug with --target, or temporarily deploy a :debug variant that includes a busybox shell. You can inspect the filesystem from outside with docker cp or docker export on a created container.

Shell entrypoints for secret templating or wait-for-it scripts have to go. Have the app read secrets from mounted files or environment variables, and retry its dependencies with backoff. Ordering that truly must happen before the app starts belongs in an init container.

# Build stage: Debian 12 (bookworm), the same family and release as the
# distroless runtime below, so native addons link against a compatible glibc.
# An Alpine (musl) builder would produce addons that fail to load at runtime.
FROM node:20-bookworm-slim AS build
WORKDIR /app

# Toolchain needed only to compile native addons (node-gyp).
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3 make g++ \
 && rm -rf /var/lib/apt/lists/*

COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .

# Runtime stage: no shell, no curl, no package manager.
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build /app /app

# Config and secrets come from env vars or mounted files read by the app,
# not from a shell entrypoint that templates them at startup.
ENV NODE_ENV=production \
    PORT=8080

EXPOSE 8080

# Exec form is required: there is no /bin/sh to interpret a shell-form command.
# healthcheck.js calls  with Node's http module
# and exits 0 on a 200 response, 1 otherwise.
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD ["/nodejs/bin/node", "/app/healthcheck.js"]

# The image's ENTRYPOINT is already the node binary, so CMD is just the script.
CMD ["server.js"]

A multi-stage Dockerfile that builds Node native addons on the same Debian release as the distroless runtime and replaces a curl-based HEALTHCHECK with an exec-form check run by the bundled node binary.

Verifying the result

Build the old and new images side by side under different tags so you can compare them directly. docker image ls gives the headline number, but the size reduction matters less than where it came from. Run docker history on both. In the new image, the layers should be the distroless base, your runtime artifacts, and nothing else. A layer that still holds a compiler toolchain, a package manager cache or your full source tree means a COPY instruction pulled in more than it should have.

Next, confirm the attack surface is actually smaller. Override the entrypoint to sh and run the container. On a non-debug distroless tag this should fail because no shell exists in the image. If it starts a shell, you are on a :debug variant or the wrong base.

Then run the same vulnerability scanner, with the same database and settings, against both images. You should see far fewer findings, because most findings come from OS packages your API never used. The remaining findings are the ones that matter. The language runtime, OpenSSL-style libraries the base still ships, and your npm or Maven dependencies can all carry vulnerabilities. Distroless removes noise, but it does not fix those.

Finally, pin the base image by digest (image@sha256:…) rather than by tag. A tag can point to different contents over time, so your builds stop being reproducible and scan results stop matching what you deploy. Digest pinning makes base updates explicit. When upstream publishes a patched base, bump the digest in a reviewed change, rebuild, and rescan.

When to avoid it, alternatives, and impact on cost and engineering time

Skip distroless if your operations depend on docker exec into running containers, entrypoint scripts that template config or wait for dependencies, or tools like curl and jq inside the image. Each of these needs a shell or utilities that distroless removes, so you would have to rebuild them before migrating. Also skip it if the image maintainer does not publish your runtime version, or does not patch it as quickly as your security policy requires. In that case you would end up maintaining your own base image, which cancels most of the benefit.

Base type Shell and package manager Typical trade-off
Distroless No Smallest attack surface, but debugging needs ephemeral or debug containers
Slim (Debian-based) Yes Familiar tooling and glibc, at the cost of more packages to scan
Alpine Yes Small image, but musl libc can break native Node modules and some JVM builds

Most of the cost is a one-time migration. You rewrite entrypoint scripts as application code or build steps, replace shell-based health checks with HTTP probes or a small binary, and update debugging runbooks so on-call engineers know how to inspect a container without a shell. After that, the main ongoing cost is reviewing digest bumps when upstream publishes a patched base.

The time savings build up over months. Scan reports list fewer packages, so triage focuses on what your service actually loads. Without a shell there is nowhere to make ad hoc fixes inside a running container, so changes go through the build. Pinned, reproducible build stages mean the image you scanned is the image you deployed.

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 multi-stage build, 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.