Key Takeaway
To build a Docker multi-stage image, name a stage with FROM node:22-alpine AS builder, build the app there, then start a new stage with FROM and take only the build output using COPY --from=builder. Only the last stage becomes the image. Measure before and after with docker image ls instead of guessing.
Your first Dockerfile works. The app runs in a container, then you run docker image ls and the SIZE column is far larger than you expected. That is normal: in a single-stage Dockerfile, the image that builds the app is the same image you ship, so the compiler, the source code, the dev dependencies and the download cache all come along. Beginners usually get stuck in three places: not knowing how to separate build from runtime, writing COPY --from wrongly, and having no way to prove the size really dropped.
This guide turns a single-stage Dockerfile into a multi-stage one in seven steps, with a measuring command at every stage. I checked every command and flag against the official Docker and npm documentation. The app is deliberately tiny, a one-file TypeScript server, so you can try everything on a laptop. The same pattern is used by this site's own Dockerfile: deps, builder and runner stages, with only the runner shipped to the server.
Want to understand first what an image actually ships and why the other stages are left behind? Take the free lesson Docker Multi-Stage Build: Shrink a Real Next.js Image in the DevOps from zero track, with an interactive explainer.
What you need
These three things are enough. If you have never written a Dockerfile at all, read the learn Docker from zero guide first, then come back.
- Docker installed and the docker command working in your terminal. The builds in this guide use BuildKit, Docker's current default builder.
- Node.js and npm installed, only to create the package-lock.json file once in step 1.
- One empty folder to practise in. Every name below (devops-lab, runner, builder) is a placeholder, rename freely.
Step 1: Set up the app and a single-stage Dockerfile
We need a picture of the before state, so start from an ordinary Dockerfile that builds and runs the app in one image. Create the files below. The .dockerignore file matters: it keeps node_modules and dist on your computer from being copied in by COPY . . inside the image.
// package.json
{
"name": "devops-lab",
"version": "1.0.0",
"private": true,
"scripts": { "build": "tsc" },
"devDependencies": { "@types/node": "22.7.5", "typescript": "5.6.3" }
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
"outDir": "dist", "strict": true
},
"include": ["src"]
}
// src/server.ts
import { createServer } from "node:http";
createServer((req, res) => res.end("hello\n")).listen(3000, "0.0.0.0");
# .dockerignore (keeps node_modules and dist out of "COPY . .")
node_modules
dist
out
.git# Run once on your machine so the lockfile exists. "npm ci" refuses to run without it.
npm install
# Dockerfile.single: the "before" picture. One FROM, so the image that
# builds the app is the same image you ship.
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci && npm run build
CMD ["node", "dist/server.js"]Save that single-stage Dockerfile as Dockerfile.single. One FROM line means one stage: everything needed to build, including TypeScript and the whole node_modules, stays in the final image.
Step 2: Measure the image size before changing anything
Change nothing until you have a number to compare against. Build the single-stage image, then read the SIZE column. Docker describes that column as the image's disk size. Your number will differ from other people's, because it depends on the base image, the CPU architecture and package versions, so record your own.
docker build -f Dockerfile.single -t devops-lab:single .
# The size column is the number to remember
docker image ls devops-lab
# REPOSITORY TAG IMAGE ID CREATED SIZE
# devops-lab single (id) (time) (your number)
# Or only the two columns you care about
docker image ls --format '{{.Tag}} {{.Size}}' devops-lab
# One row per layer, with the instruction that made it
docker history devops-lab:singleRun docker history as well. It prints one row per layer with the instruction that made it and its size. Look at the layers from COPY . . and RUN npm ci: that is where the source code and node_modules pile up, and you need neither just to run the compiled output.
Step 3: Split into two stages, builder and runner
Now change it to two stages. Every FROM starts a new stage on a clean base image, and AS names that stage. A named stage is safer than a number (0, 1, ...), because COPY stays correct if the stage order changes. The runner stage has no source and no TypeScript. It takes one folder from the builder with COPY --from.
# Dockerfile (version 1: two stages)
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM node:22-alpine AS runner
WORKDIR /app
# Reach back into the builder and take ONLY the compiled output
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]Two things to understand. First, the path after --from=builder is read from the root of the builder stage's filesystem, not from your folder, so it must be an absolute path inside that stage. Second, only the last stage ships. The builder stage is still built, but it does not become the image you run or push.
Step 4: Split out a deps stage so the cache gets used
Add a third stage that only installs packages. It copies just two files, package.json and package-lock.json, then runs npm ci. The builder then takes its node_modules with COPY --from=deps. The npm documentation says npm ci is meant for automated environments such as CI and deployment, and that it requires a package-lock.json.
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
# Only the two files npm needs: this layer is rebuilt only when they change
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]The payoff shows on the next build. If you only edit src/server.ts, the deps layer is unchanged, so npm ci does not run again. This is also the layout of this site's Dockerfile, with one addition: there, npm ci uses a cache mount for npm's download folder.
If your app needs runtime packages when it runs (express, for instance), the runner still needs node_modules. Do not copy the whole node_modules from the builder. Make a separate stage that runs npm ci with the --omit=dev option (the omit option is listed in the npm ci documentation) and copy from that stage only.
Step 5: Copy only what runs, and do not run as root
This is where beginners slip most, so compare the three COPY lines below. COPY without --from reads from your folder (the build context), not from another stage. And copying the whole node_modules from the builder brings back every dev dependency you just wanted to leave behind.
# Wrong: no --from, so Docker looks in YOUR folder, and "dist" is
# excluded by .dockerignore, so the build stops here with "not found"
COPY dist ./dist
# Wrong: ships every dev dependency again and undoes the whole point
COPY --from=builder /app/node_modules ./node_modules
# Right: name the stage, name the path inside that stage
COPY --from=builder /app/dist ./distThen finish the runner with a non-root user. Docker's best-practice guide recommends a non-root USER when a service can run without privileges, and recommends an explicit UID and GID. Watch the order: COPY without --chown creates files owned by UID and GID 0, which is root, so add --chown before the USER line takes effect.
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# A fixed id (1001), because ids chosen automatically can differ between rebuilds
RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 -G app app
# Without --chown the files belong to root (UID and GID 0)
COPY --from=builder --chown=app:app /app/dist ./dist
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]This runner stage now holds one build folder and one user. If your app later needs to write to a folder, create that folder and give it to the app user in a COPY or RUN before USER, because once USER takes effect you are no longer root.
Step 6: Rebuild, compare, then run it
Build the multi-stage version under another tag, then compare the two tags in docker image ls. Also run id inside the container to prove the user, and curl to prove the app is still alive. Expected results: uid=1001 from the id command, and the text hello from curl.
docker build -t devops-lab:v1 .
docker image ls devops-lab
# You now see two tags, single and v1. Compare the SIZE column yourself.
# Who does the container run as? Expect uid=1001
docker run --rm devops-lab:v1 id
# Does it still work?
docker run -d --name lab -p 3000:3000 devops-lab:v1
curl http://localhost:3000
# hello
docker rm -f labBe honest about the numbers. Multi-stage removes the build tools, but the base image still sets the floor on size. For an app this small the difference may not be dramatic. The big gap shows up on real apps with many dev dependencies, such as Next.js, and the related lesson below shows how.
Do not conclude multi-stage worked just because the build is green. Prove it with three checks: docker image ls compares the size of two tags, docker run --rm image-name id shows the user is not root, and the app answers on its port. One COPY --from with the wrong path can produce a small image that does not run at all.
Step 7: Build a single stage with --target
Sometimes you want to look inside a middle stage, such as builder, without building all the way to the end. Use docker build --target followed by a stage name: the build stops at that stage. The Docker documentation says BuildKit builds only the stages the target depends on, while the legacy builder processes every stage up to the target. A FROM scratch stage is an empty image, a good fit for a stage that exists only to be exported, and the local exporter writes its contents to a plain folder instead of making an image.
# Stop at the builder stage and tag it, to look inside
docker build --target builder -t devops-lab:builder .
docker run --rm devops-lab:builder ls /app
# Add this stage between builder and runner in the Dockerfile:
FROM scratch AS dist-export
COPY --from=builder /app/dist /
# Export its files to a folder. No image is created.
docker build --target dist-export --output type=local,dest=./out .
ls out
# server.jsOne note from this site's own Dockerfile. A build with no --target produces the last stage in the file, so I put helper stages such as cache-export before runner, not after, to keep runner the stage that ships. A helper stage is also not always free: if the build cache is gone, every stage above it runs again.
Common errors and how to fix them
Six symptoms that appear most often the first time you write a multi-stage Dockerfile, most frequent first.
| Symptom | Cause | Fix |
|---|---|---|
| The build stops at a COPY line with a not found message, though the folder exists on your computer | COPY without --from reads from the build context, and that folder is excluded by .dockerignore or only exists in the builder stage | Add --from=builder and write the absolute path inside that stage, for example COPY --from=builder /app/dist ./dist |
| The build fails because a stage name is not found, or the image pulled is not the one you meant | The name after --from is mistyped or does not match AS. An unknown name can be treated as an image name from a registry | Match the name exactly to the one after AS, and use stage names, not numbers |
| The multi-stage image is barely smaller than the single-stage one | The whole node_modules is copied from the builder, or the base image already makes up most of the size | Copy only the build output, compare docker history for both tags, and consider a smaller base image |
| The container dies at once with a module not found error when it runs | The runner only received the compiled output, but the app needs runtime packages in node_modules | Make a stage that runs npm ci --omit=dev and copy node_modules from that stage into the runner |
| The app fails to write a file or folder, with a permission denied error | Files were copied without --chown so root owns them, while the process runs as the app user after USER | Add --chown=app:app to the COPY, or create the writable folder before the USER line |
| Every small source change makes npm ci run again from scratch | COPY . . sits before npm ci, so the install layer is invalidated too | Copy package.json and package-lock.json first, run npm ci in a deps stage, and copy the source in the builder stage |
Checklist before you push the image
- Every stage is named with AS, and every COPY --from uses that name, not a number.
- The runner holds only the build output and runtime packages, with no source, TypeScript or dev dependencies.
- You compared the size of both tags with docker image ls and wrote down your own numbers.
- docker run --rm image-name id shows a non-root user with the UID you set.
- The app answers on its port, and helper stages sit before the stage that ships.
Go deeper
An interactive walkthrough of what each stage carries is in the free lesson Docker Multi-Stage Build: Shrink a Real Next.js Image. If your app is Next.js, see how standalone output decides what the runner holds in Next.js standalone Docker image size, and if you want a leaner base image, read distroless Docker image optimisation.
A rule you can carry: build in one stage, run in another, and copy between stages only what actually runs. Then prove it with the numbers from your own docker image ls, not from an article.

