DevOps & Infrastructure

A Multi-Stage Dockerfile That Ships Only the Build Output

Build a static site in a Node stage, ship only the generated files on nginx, and use --target, docker image ls, and docker history to confirm what actually ended up in the final image.

6 min readUpdated Tuesday, Oct 13, 2026
dockercontainersnginxdeployment

A humpback whale’s tail lifting from the sea, water streaming off it, in front of snowy mountains

A single-stage Dockerfile for a static site ships everything it took to build the site: Node, npm, node_modules, the source, sometimes .git. All of that, to serve a folder of HTML files.

A multi-stage build fixes this with one idea. A Dockerfile can have several FROM lines, and each one starts a fresh image called a stage. Only the last stage becomes the image you ship. The earlier stages are scaffolding, and you copy out of them only what you need. You serve the plate, not the kitchen.

The Dockerfile

The example is a static site whose npm run build writes to dist/. Astro and Vite do this by default; adjust the path if your generator uses something else.

# syntax=docker/dockerfile:1

FROM node:24-trixie-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

FROM nginx:1.30-alpine AS runtime
COPY --from=build /app/dist/ /usr/share/nginx/html/

The parts that matter:

  • AS build names the stage, and COPY --from=build copies from it by name. The final stage holds nginx and your files. Node never makes it in.
  • Lockfile first, source second. Docker caches each step and reuses it until that step’s inputs change. Because only package.json and package-lock.json feed the npm ci step, editing a blog post doesn’t trigger a fresh install.
  • --mount=type=cache,target=/root/.npm keeps npm’s download cache between builds without putting it in any image layer. When the lockfile does change, npm only downloads what’s new.
  • npm ci installs exactly what the lockfile says. If there’s no lockfile, or it disagrees with package.json, it stops with an error instead of improvising. That’s what you want from a build.
  • The tags. Node 24 is the newest LTS line and nginx 1.30 is the current stable branch. Pinning to a release line instead of latest means a rebuild next month stays on the same lines; you move to the next one on purpose.

The # syntax=docker/dockerfile:1 line asks BuildKit to use the latest stable Dockerfile syntax. Docker’s reference recommends it for most users.

Add .dockerignore before the first build

node_modules
dist
.git
.env
*.log

COPY . . copies the build context, meaning the directory you pass to docker build. Without this file, your laptop’s node_modules gets copied over the one npm ci just installed, along with any native binaries built for your laptop’s OS instead of Linux. .env and .git come along too. They wouldn’t reach the final image here, but they’d sit in the build stage and its cache. The file has to live in the root of the build context.

The build output shows how much was sent (shown here with --progress=plain):

#9 transferring context: 230B done

If that number is hundreds of megabytes for a small site, something isn’t being ignored.

Build it, then check what you shipped

docker build -t mysite:latest .
docker build --target build -t mysite:build .
docker image ls mysite

--target build stops after the build stage and tags it. That’s roughly the image a single-stage Dockerfile would have given you, so it makes a handy comparison. The gap between the two rows is everything you left behind.

On Docker Engine 29 and later, docker image ls shows two size columns instead of one SIZE. CONTENT SIZE is the compressed layer data, close to what a push or pull moves over the network. DISK USAGE adds the unpacked copy on your machine. Compare the same column across rows and the point makes itself.

The build stage is also a good place to poke around when the output path is in doubt:

docker run --rm mysite:build ls dist

If COPY --from=build points at the wrong folder, the build fails, and the error ends with something like "/app/build": not found. That means the generator wrote somewhere else, not that Docker lost your files.

Now look at the final image’s layers:

docker history mysite:latest
IMAGE          CREATED          CREATED BY                                      SIZE
051bd4bac981   27 seconds ago   COPY /app/dist/ /usr/share/nginx/html/ # bui…   24.6kB
<missing>      2 weeks ago      RUN /bin/sh -c set -x     && apkArch="$(cat …   50.8MB
<missing>      2 weeks ago      ENV ACME_VERSION=0.4.1                          0B
...

The top row is your site. Everything below it is the nginx base image, which is where <missing> comes from: those layers were pulled, not built here, so there’s no local image ID to show. It isn’t an error. What you won’t find anywhere in the list is npm ci.

Edit some content and rebuild. In plain progress output, the dependency step is marked CACHED:

#12 [build 4/6] RUN --mount=type=cache,target=/root/.npm npm ci
#12 CACHED

If that step reruns after every content edit, a COPY . . has crept above it.

The stage that never runs

BuildKit, the default builder in current Docker Desktop and Docker Engine, only builds the stages the target actually depends on. Say you add a test stage:

FROM build AS test
RUN npm test

A plain docker build . will skip it without a word, because runtime copies from build, not from test. Your tests didn’t pass; they didn’t run. Ask for them explicitly:

docker build --target test .

Alternatively, run them in CI before the image build. Either way, check that they ran.

Mistakes that undo the benefit

  • Copying the whole stage. COPY --from=build /app /usr/share/nginx/html/ ships the source and node_modules back into the image. Worse, it puts them in the web root, where nginx will happily serve your package.json to anyone who asks.

  • Secrets in ARG or ENV. Docker’s docs say plainly that build arguments and environment variables are the wrong way to pass secrets, because they persist in the image. Multi-stage doesn’t scrub them: the build stage still exists in the cache, and in any image built with --target build. Use a secret mount instead:

    RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
    docker build --secret id=npmrc,src=$HOME/.npmrc -t mysite:latest .
  • Forgetting the runtime needs for a server app. This example ships static files, so the final stage needs no Node at all. A Node server’s final stage still needs Node and its production dependencies (npm ci --omit=dev). The principle is the same; the plate is just bigger.

References

Cover photo: Unsplash.