Dockerfile Multi-Stage Build: Cut Image Size Fast

By Anders Vik ·

Dockerfile Multi-Stage Build: Smaller Images Step by Step

Somebody on your team runs docker images and asks why a Node app that fits in a 40 MB zip file produces a 1.2 GB container. Nobody has a good answer. The Dockerfile was copied from a README months ago, it builds, it ships, and nobody has touched it since. That's the normal path to a bloated image: not one bad decision, just a single-stage Dockerfile doing a job it was never designed to do well.

A dockerfile multi-stage build fixes this by splitting the "compile the app" step from the "run the app" step, so the final image only carries what the running container actually needs. This article walks through that split as four concrete edits to one Dockerfile, with a size number attached to each base image so you can see which line is doing the work. Then it covers the parts most tutorials skip: copying files between stages without dragging the whole build tree along, when Alpine helps and when it quietly makes your build ten times slower, and how Docker Compose knows which stage to build.

What a Multi-Stage Docker Build Actually Does

The Docker documentation defines it plainly: "you use multiple FROM statements in your Dockerfile. Each FROM instruction can use a different base, and each of them begins a new stage of the build." (Docker Build documentation) Each stage is its own isolated filesystem. It can install a compiler, run tests, download the entire npm dependency tree, and none of that survives into the final image unless you explicitly copy it over with COPY --from.

That's the whole mechanism. One Dockerfile, multiple stages, only the last stage in the file (or the stage you name as the build target) ends up as the image you push and run. Everything earlier is scaffolding that produces an artifact and then gets thrown away.

Here's the naive single-stage version most tutorials start from, and the one most people are still running in production:

FROM node:22-bookworm
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

Nothing here is wrong, exactly; it builds and it runs. But look at what's sitting inside that final image: the full Debian-based Node build (compilers, headers, package manager caches), every file in your repository including .git if you forgot a .dockerignore, and every entry in node_modules, devDependencies included, because plain npm install doesn't distinguish between them. node:22-bookworm alone is 408.4 MB compressed before your app adds a single byte (Docker Hub, checked September 2026). That's the number you're paying for on every pull, every deploy, every CI run.

Step 1: split into a builder stage and a runtime stage

The first edit is naming two stages instead of one:

FROM node:22-bookworm AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /app

The AS builder and AS runtime labels aren't decoration. Docker's own guidance points out that naming stages means later COPY --from instructions "don't break" if you reorder the Dockerfile, because you're referencing a name instead of a fragile numeric index (Docker build best practices). Skip the names and you can still reference stages by index (--from=0), but the moment someone inserts a stage above yours, that index points at the wrong thing.

Notice the second FROM also switches base images, from node:22-bookworm to node:22-bookworm-slim. That single swap removes roughly 330 MB of compressed download before you've touched your application code, because the slim variant drops the compiler toolchain, most locale data, and other build-time packages the runtime container never needs. This is the step that does the heavy lifting; everything after it is refinement.

Step 2: copy only the artifact, not the whole directory

This is where most people who "switched to multi-stage" end up reporting that the image is basically the same size as before. The cause, almost every time, is a line like this:

COPY --from=builder /app ./

That copies the entire builder directory, source files, devDependencies, build cache and all, straight into the runtime stage. You've split the build into two stages and then dragged all the weight across the split anyway. The fix is to copy only what the app needs to run, usually a compiled output directory:

COPY --chown=node:node --from=builder /app/dist ./dist

COPY --from accepts a stage name (as here), a numeric index, or even an external image reference, like pulling a config file straight out of an official nginx image without running it (COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf).

The --chown flag matters if you're running as a non-root user later in the file: it accepts names or numeric IDs, and if you only supply a user, the group ID is set to match it automatically. Skip it and the copied files still belong to root, which means the app fails to start the moment you drop privileges with USER. "Permission denied after USER app" is one of the most common complaints in multi-stage troubleshooting threads, and the fix is almost always this one flag.

Step 3: drop devDependencies from the final image

A tempting shortcut is to just copy node_modules from the builder stage instead of reinstalling in the runtime stage. Don't. The builder's node_modules tree includes devDependencies (test runners, bundlers, type checkers) that have no business in production, and any native modules in there were compiled against the builder image's C library. If your runtime base uses a different libc (more on that in a moment), those compiled bindings won't load.

The correct move is a second, leaner install in the runtime stage:

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Copying the manifest files (package.json, package-lock.json) before the rest of the code, in both stages, is a caching detail worth keeping: as long as those two files don't change, Docker can reuse the cached install layer instead of running npm ci again. --omit=dev tells npm to skip devDependencies entirely, and npm cache clean --force clears the package cache so it doesn't sit unused inside the image layer.

That closing CMD line is written in exec form, the array syntax Docker recommends over a plain shell string. For the full difference between CMD and ENTRYPOINT, and why exec form matters for signal handling, see this site's CMD vs ENTRYPOINT guide.

Step 4: pick the right final base, and know what Alpine costs you

The last lever is the base image itself. Here's what the same runtime stage costs depending on which tag you land the last FROM on (all figures are compressed download sizes for linux/amd64, from the image's Docker Hub tags):

Base imageCompressed sizeWhat it drops relative to full
node:22-bookworm408.4 MBbaseline; full Debian, compiler toolchain, dev headers
node:22-bookworm-slim79.9 MBno compilers, no dev headers, most locale data stripped
node:22-alpine57.7 MBmusl libc, BusyBox userland, smaller base entirely

The jump from full to slim (roughly 330 MB saved) dwarfs the jump from slim to Alpine (about 22 MB more). That's worth sitting with, because a lot of advice online treats Alpine as the default answer to "shrink my image" when for most runtimes, slim already captured most of the win. Compiled languages tell a different story: golang:1.25-bookworm runs 289.8 MB, but a statically linked Go binary dropped into alpine:3.22 (3.8 MB) is a two-orders-of-magnitude drop, which is why nearly every multi-stage tutorial uses Go as its example. Node and Python gains are real, just proportionally smaller, because the language runtime itself still has to ship inside the container.

Alpine's appeal is real: Docker's own best practices page recommends it as "tightly controlled and small in size (under 6 MB), while still being a full Linux distribution." But it uses musl libc instead of glibc, and that trips up two common cases. First, native Node or Python modules that depend on manylinux-compiled wheels: many packages still only ship glibc-compatible wheels, so pip falls back to compiling from source on Alpine. A docker-library maintainer issue documented a pandas build going from about one minute to twenty minutes purely from this fallback (docker-library/docs issue #904).

Musllinux wheels exist now, but ecosystem coverage is still partial, so "just use Alpine" from a five-year-old blog post can quietly cost you a slow CI pipeline today. Second, a binary landing in scratch or a mismatched libc image can produce "no such file or directory" even though the binary is clearly sitting right there; the missing file is usually the dynamic linker, not your program. The fix is either CGO_ENABLED=0 for Go builds, or landing on Alpine/Debian-slim instead of scratch when the binary isn't fully static.

Rule of thumb: default to -slim, reach for Alpine when your final image is a static binary or a pure-JS/Python app with no native dependencies. Check the official image's own documentation for the specifics of a given runtime; the Node Docker Hub image page lists exactly what each variant (bookworm, slim, alpine) drops.

How much smaller does the final image actually get

The honest answer is: it depends on your app, so treat any single "we went from X to Y" headline with some caution (they're almost always self-reported, from one specific codebase). What you can rely on are the fixed points above. Going from a single-stage build on node:22-bookworm to a multi-stage build ending on node:22-bookworm-slim removes about 330 MB of base image weight before you account for skipping devDependencies and leftover source files. Those extra savings scale with your app, not with the base image, so run docker images before and after your own refactor; that number is worth more than any borrowed statistic.

Multi-stage builds with Docker Compose

If you're running this app through Compose rather than a bare docker build, the piece nearly every multi-stage tutorial skips is telling Compose which stage to build. Without it, Compose defaults to building the last stage in the file, which usually happens to be the right one by accident, until someone adds a debug stage after it and the build silently changes behavior.

services:
  web:
    build:
      context: .
      target: runtime
      args:
        NODE_ENV: production
    ports:
      - "3000:3000"

The target key under build "defines the stage to build as defined inside a multi-stage Dockerfile" per the Compose Build Specification. Point it at runtime and Compose stops there. That matters because BuildKit, the default builder on current Docker Engine and Desktop, "only builds the stages that the target stage depends on," where the legacy builder used to build every preceding stage regardless. If you add a separate dev stage for local work (nodemon, source maps, no minification) later in the same Dockerfile, swapping target: dev in a local override file gives you a completely different image from the same source.

args under build passes values through to ARG instructions inside the Dockerfile, which is how NODE_ENV above reaches the build. For a deeper look at passing configuration through Compose, including when to use an env file instead of inline values, see the site's Compose environment variables guide.

Once the container is running, it's worth checking outside the build tooling that it actually responds; httpcheck.tools will confirm the containerized app is answering requests on the port you exposed.

Things that bite

  • ARG scope resets across stages. An ARG declared inside one stage is only visible in that stage. An ARG placed before the first FROM has to be redeclared inside any stage that wants to use it, or it's silently empty. This is the usual cause of "my ARG is empty in the second stage."
  • .dockerignore is still required. Multi-stage builds don't shrink the build context you send to the daemon before any stage runs. Skip .dockerignore and a stray node_modules or .git directory still gets uploaded on every build, even if it never lands in the final image.
  • Debug a failing stage directly. docker build --target builder -t debug . stops the build at the named stage and lets you inspect it, instead of guessing which of several stages actually broke.
  • Pin your tags. node:22-bookworm-slim gets rebuilt periodically; pin to a digest if you need byte-for-byte reproducibility across environments.

Generate a multi-stage Dockerfile without writing one by hand

If you'd rather not assemble this stage by stage, the Dockerfile generator on this site produces a multi-stage Dockerfile by default for Node, Python, PHP, Go, and static-site presets: manifest copied before source, package caches cleaned in the same RUN, a non-root user set up correctly, exec-form CMD, a runtime-appropriate HEALTHCHECK, and a matching .dockerignore. It runs entirely client side.

Once you have a Dockerfile (generated or hand-written), run it through the validator, which checks around forty common mistakes, including several covered here, and flags them with line numbers. If you're wiring the service into a Compose stack afterward, the Compose generator handles the build.target and healthcheck wiring for you.

How to verify the split actually worked

Build both versions and compare directly, don't trust the Dockerfile alone to tell you the truth:

docker build -t app:single -f Dockerfile.single .
docker build -t app:multi -f Dockerfile .
docker images | grep app

Remember that docker images reports uncompressed sizes on disk, which will be larger than the compressed download figures cited earlier; both numbers are legitimate, they just measure different things. If the multi-stage version isn't meaningfully smaller, the most likely culprit is a COPY --from=builder /app ./ line copying the entire build tree instead of just the compiled output.

Frequently Asked Questions

What is a multi-stage Docker build?

A multi-stage Docker build is a Dockerfile that contains more than one FROM instruction, where each FROM starts a new, isolated build stage. Only the final stage (or whichever stage you name as the build target) becomes the shipped image; earlier stages exist purely to compile code, run tests, or install build tooling, and none of that survives into the final image unless it's explicitly copied over with COPY --from.

How much smaller does a multi-stage image get?

It depends on the app and the language, so treat specific percentage claims from other write-ups with some skepticism. What's measurable and stable is the base image delta: switching a Node runtime stage from node:22-bookworm (408.4 MB compressed) to node:22-bookworm-slim (79.9 MB) removes about 330 MB before your app code even factors in. Compiled languages like Go see far bigger proportional drops, since a static binary in Alpine or scratch can weigh under 10 MB against a 290 MB build image.

Can I copy files from an earlier stage?

Yes, that's what COPY --from is for. It accepts a named stage (COPY --from=builder /app/dist ./dist, with the name set via FROM ... AS builder), a numeric stage index (COPY --from=0), or even a separate image entirely (COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf). Named stages are safer long-term since inserting or reordering stages later won't silently shift a numeric index onto the wrong source.

Do multi-stage builds work with Docker Compose?

Yes. Compose reads the same multi-stage Dockerfile and uses the target key under a service's build section to pick which stage to build, for example build: { context: ., target: runtime }. Leave target out and Compose defaults to the last stage in the file. With BuildKit, Compose only builds the stages that the target stage actually depends on, so unrelated stages earlier in the Dockerfile are skipped.