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.

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 buildnames the stage, andCOPY --from=buildcopies 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.jsonandpackage-lock.jsonfeed thenpm cistep, editing a blog post doesn’t trigger a fresh install. --mount=type=cache,target=/root/.npmkeeps 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 ciinstalls exactly what the lockfile says. If there’s no lockfile, or it disagrees withpackage.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
latestmeans 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 andnode_modulesback into the image. Worse, it puts them in the web root, where nginx will happily serve yourpackage.jsonto anyone who asks. -
Secrets in
ARGorENV. 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 cidocker 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
- Docker documentation: Multi-stage builds
- Docker documentation: Dockerfile reference
- Docker documentation: Build context and .dockerignore
- Docker documentation: Optimize cache usage in builds
- Docker documentation: Build secrets
- Docker documentation: BuildKit
- Docker documentation: docker image history
- Docker Engine 29 release notes
- npm documentation: npm ci
- Node.js releases
- nginx: download (stable and mainline versions)
Cover photo: Unsplash.