Shrink your image with multi-stage builds
- Beginner
- 20 min
- Published
- Verified
What you'll learn
- Explain why a single-stage image carries tools and files the app never uses at runtime
- Write a Dockerfile with named build stages and a separate final stage
- Use
COPY --fromto move only the build output into the final image - Compare image sizes with
docker image lsand confirm what was left out - Build a single stage with
--targetto inspect or debug it
A straightforward Dockerfile for a Node.js app starts FROM node:22, copies
the code in, runs npm ci, and sets a start command. It works, but the image it
produces is close to 2 GB on disk. It carries a C compiler, Python, Git, and
hundreds of Debian packages that came with the base image, plus every test and
development tool in your package.json. Your app uses none of them once it’s
running. They still get pulled onto every server, and scanned for
vulnerabilities, every time you deploy.
There’s a second problem when the app has a frontend. The React code in the sample to-do app has to be compiled into static HTML, CSS, and JavaScript before the backend can serve it. Doing that in the same image means shipping the whole frontend toolchain too.
A multi-stage build fixes both. You build in one or more stages that have every tool you need, then start a fresh, small final stage and copy in only the files that run. The build tools never reach the image you ship.
In this guide, you’ll turn a single-stage Dockerfile into a three-stage one, build it, and measure the result: the image drops from 1.87 GB to about 400 MB, and it serves the full app instead of just the API.
Prefer to watch? This video covers the same idea with a Java Spring Boot app, where the build stage has the full JDK and Maven and the final stage has only a Java runtime. It predates this guide and uses a different sample app.
Before you start
You can follow this whole guide in your browser. The terminals on this page are simulations: click through them to see what each command does and prints.
To run it for real on your own machine, you need:
- Docker Desktop or Docker Engine. See Get Docker.
- Git, to clone the sample app.
Your output will differ in the details: image IDs, timings, and digests change with every build.
Step 1: Build the single-stage version
Clone the sample app and move into it:
$ git clone https://github.com/docker/getting-started-todo-app
$ cd getting-started-todo-appThe repository already has a complete Dockerfile. You’ll come back to it at
the end. For now, replace its contents with this single-stage version, which
installs the backend’s dependencies and copies in its source:
FROM node:22
WORKDIR /usr/local/app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci
COPY backend/src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]Build it with a tag that says what it is, so you can compare it later:
$ docker build -t todo-app:single-stage .The first build downloads the node:22 base image and installs the backend’s
packages, so it takes a minute or so.
Step 2: See what the image carries
Look for a few things the running app never needs: the C compiler and Python
that come with node:22, and two of the backend’s development tools, jest
(the test runner) and nodemon (which restarts the server while you code).
docker run --rm starts a throwaway container that runs one command and is
removed when it exits:
Host machine
docker run --rm todo-app:single-stage ls -d /usr/bin/gcc /usr/bin/python3 node_modules/jest node_modules/nodemon
/usr/bin/gcc /usr/bin/python3 node_modules/jest node_modules/nodemon
All four are there. npm ci installs devDependencies as well as
dependencies, and node:22 is a full Debian image with build tools included,
because some npm packages compile native code when they install.
Now run the app and request its home page:
Host machine
docker run -d --name todo -p 3000:3000 todo-app:single-stage
fd1b696250a27b05b26e1dcf26e67dc0954d5e3157fc4fb4fdc91854fd5bc671
curl localhost:3000/
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Error</title> </head> <body> <pre>Cannot GET /</pre> </body> </html>
curl localhost:3000/api/greeting
{"greeting":"Hello world!"}docker rm -f todo
todo
The API answers, but there’s no web page. The backend serves the frontend from
src/static, and nothing in this Dockerfile builds the React app in client/
to put it there. That build needs Vite and a few hundred more npm packages,
none of which the backend needs to run.
How a multi-stage build works
A Dockerfile can have more than one FROM instruction. Each FROM starts a new
stage with its own base image and its own filesystem. Name a stage with
AS, and a later stage can copy files out of it with COPY --from=<name>.
Only the last stage becomes the image you tag. The earlier stages exist during the build, and their results stay in the build cache to speed up the next build, but none of their layers end up in your image unless you copy something out of them.
Here’s the plan for the to-do app:
client-buildinstalls the frontend’s packages, including Vite, and runsnpm run buildto produce static files indist/.backend-buildinstalls the backend’s packages with--omit=dev, sojest,nodemon, andprettierare never installed. It uses the fullnode:22image because some packages, such assqlite3, may need build tools while they install.- The final stage starts from
node:22-slim, a much smaller variant of the same Node.js image without the compilers and extra packages. It copies in the two build results and the backend source, and nothing else.
Step 3: Write the multi-stage Dockerfile
Replace the contents of Dockerfile with this:
FROM node:22 AS client-build
WORKDIR /usr/local/app
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client ./
RUN npm run build
FROM node:22 AS backend-build
WORKDIR /usr/local/app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-slim AS final
WORKDIR /usr/local/app
ENV NODE_ENV=production
COPY --from=backend-build /usr/local/app/node_modules ./node_modules
COPY backend/package.json ./
COPY backend/src ./src
COPY --from=client-build /usr/local/app/dist ./src/static
EXPOSE 3000
CMD ["node", "src/index.js"]The lines worth a closer look:
FROM node:22 AS client-build.ASnames the stage, so later instructions can refer to it. Each build stage copies itspackage.jsonand lockfile before its source, so a code change doesn’t rerunnpm ci.COPY --from=backend-build /usr/local/app/node_modules ./node_modules. Without--from,COPYreads from your build context, the files on your machine. With it,COPYreads from another stage’s filesystem. The path is where the file lives in that stage.COPY --from=client-build /usr/local/app/dist ./src/static. The built frontend lands exactly where the backend looks for static files.ENV NODE_ENV=production. Many Node.js libraries, Express among them, switch to their production behavior when this is set.FROM node:22-slim AS final. The last stage is the one that becomes your image, whatever it’s called. Naming itfinalis optional, but it makes the build output easier to read, and it’s the name the repository’s own Dockerfile uses.
Before you build, check that the plan makes sense. For each item, pick every stage it ends up in:
- Vite: only
client-build. It’s a frontenddevDependency, and the final stage never copies that stage’snode_modules. - The compiled frontend in
dist/:client-build, where it’s created, and the final stage, which copies it tosrc/static. - Express and
sqlite3:backend-build, which installs them, and the final stage, which copiesnode_modulesacross. jest: no stage.npm ci --omit=devskipsdevDependencies.gccand Python: both build stages, which use the fullnode:22image. Not the final stage, which usesnode:22-slim.- The backend source: only the final stage, copied from your build context.
Step 4: Build it
Build the image with the plain todo-app tag:
Host machine
docker build -t todo-app .
[+] Building 37.7s (20/20) FINISHED docker:default => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 614B 0.0s => [internal] load metadata for docker.io/library/node:2 0.0s => [internal] load metadata for docker.io/library/node:2 0.0s => [internal] load .dockerignore 0.0s => => transferring context: 52B 0.0s => [client-build 1/6] FROM docker.io/library/node:22@sha 0.0s => => resolve docker.io/library/node:22@sha256:82693da96 0.0s => [internal] load build context 0.0s => => transferring context: 185.29kB 0.0s => [final 1/6] FROM docker.io/library/node:22-slim@sha25 1.1s => => resolve docker.io/library/node:22-slim@sha256:de17 0.0s => CACHED [client-build 2/6] WORKDIR /usr/local/app 0.0s => CACHED [backend-build 3/4] COPY backend/package.json 0.0s => [client-build 3/6] COPY client/package.json client/pa 0.0s => [backend-build 4/4] RUN npm ci --omit=dev 36.6s => [client-build 4/6] RUN npm ci 3.3s => [final 2/6] WORKDIR /usr/local/app 0.1s => [client-build 5/6] COPY client ./ 0.1s => [client-build 6/6] RUN npm run build 2.1s => [final 3/6] COPY --from=backend-build /usr/local/app/ 0.1s => [final 4/6] COPY backend/package.json ./ 0.0s => [final 5/6] COPY backend/src ./src 0.0s => [final 6/6] COPY --from=client-build /usr/local/app/d 0.0s => exporting to image 0.9s => => exporting layers 0.7s => => exporting manifest sha256:fb23a2b831f85cc30c853b38 0.0s => => exporting config sha256:be7948e17e22dfbf6f4140a7bf 0.0s => => exporting attestation manifest sha256:fbccbd9fe9dd 0.0s => => exporting manifest list sha256:4af76936057c2930af2 0.0s => => naming to docker.io/library/todo-app:latest 0.0s => => unpacking to docker.io/library/todo-app:latest 0.2s
Each line is labeled with its stage: client-build, backend-build, and
final. The two build stages don’t depend on each other, so BuildKit runs
them at the same time: RUN npm ci for the client finished while the
backend’s install was still going. The two CACHED steps are ones the
single-stage build in Step 1 already ran, with the same base image and the
same files.
Step 5: Compare the two images
List both tags:
Host machine
docker image ls todo-app
IMAGE ID DISK USAGE CONTENT SIZE EXTRA todo-app:latest 4af76936057c 401MB 92.4MB todo-app:single-stage 320b665e0145 1.87GB 449MB
The multi-stage image is less than a quarter of the size. Content size is what gets downloaded when someone pulls the image, compressed; disk usage is what it takes up once unpacked.
Most of the savings come from the base image. node:22-slim on its own is
about 350 MB on disk, so the app adds only about 50 MB on top of it, while the
full node:22 image, with its compilers and Debian packages, makes up most of
the single-stage image. Leaving out dev dependencies saves less, but it follows
the same rule: nothing that only matters while you build or develop.
Now run the check from Step 2 against the new image:
Host machine
docker run --rm todo-app ls -d /usr/bin/gcc /usr/bin/python3 node_modules/jest node_modules/nodemon
ls: cannot access '/usr/bin/gcc': No such file or directory ls: cannot access '/usr/bin/python3': No such file or directory ls: cannot access 'node_modules/jest': No such file or directory ls: cannot access 'node_modules/nodemon': No such file or directory
All four are gone. Each is one less thing to download, and one less thing a vulnerability scanner reports for code your app never runs.
Step 6: Run the app
Start a container from the new image:
Host machine
docker run -d --name todo -p 3000:3000 todo-app
1287351d84f4b82297e5a10315124b7412bb960af41ad267d382fbb7ce829dda
curl localhost:3000/
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
href="https://fonts.googleapis.com/css?family=Lato&display=swap"
rel="stylesheet"
/>
<title>Todo App</title>
<script type="module" crossorigin src="/assets/index-B1KJ-TSY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkVKuUx6.css">
</head>
<body>
<div id="root"></div>
</body>
</html>docker logs todo
Using sqlite database at /etc/todos/todo.db Listening on port 3000
This time / returns the app’s page, built by the client-build stage. Open
http://localhost in your browser and add a to-do
to see the whole app working: the React frontend and the API from one
container.
Step 7: Build one stage with --target
Sometimes you want an intermediate stage as an image of its own: to check what
a build step produced, to debug a failing build, or to run a development or
test stage. --target stops the build at the stage you name:
Host machine
docker build --target client-build -t todo-app:client-build .
[+] Building 3.2s (11/11) FINISHED docker:default => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 614B 0.0s => [internal] load metadata for docker.io/library/node:2 0.0s => [internal] load .dockerignore 0.0s => => transferring context: 52B 0.0s => [client-build 1/6] FROM docker.io/library/node:22@sha 0.0s => => resolve docker.io/library/node:22@sha256:82693da96 0.0s => [internal] load build context 0.0s => => transferring context: 817B 0.0s => CACHED [client-build 2/6] WORKDIR /usr/local/app 0.0s => CACHED [client-build 3/6] COPY client/package.json cl 0.0s => CACHED [client-build 4/6] RUN npm ci 0.0s => CACHED [client-build 5/6] COPY client ./ 0.0s => CACHED [client-build 6/6] RUN npm run build 0.0s => exporting to image 3.1s => => exporting layers 2.3s => => exporting manifest sha256:b5cb84da8e1a37c3600855da 0.0s => => exporting config sha256:d94c4eae1a1133437a0cd58c5f 0.0s => => exporting attestation manifest sha256:64579af2ce6b 0.0s => => exporting manifest list sha256:432c9788cfce33c3c53 0.0s => => naming to docker.io/library/todo-app:client-build 0.0s => => unpacking to docker.io/library/todo-app:client-bui 0.8s
docker run --rm todo-app:client-build ls dist
assets index.html vite.svg
Two things to notice. Every step is CACHED, because the full build already ran
them. And backend-build doesn’t appear at all: BuildKit builds only the stages
the target depends on.
This image is the same size as the single-stage one, around 1.85 GB, because it
is node:22 plus the frontend’s toolchain. That’s fine for a build stage, which
is exactly why it shouldn’t be the image you ship.
The repository’s own Dockerfile
The Dockerfile you replaced in Step 1 is a fuller version of the same idea.
Bring it back with Git and list its stages:
Host machine
git restore Dockerfile
grep '^FROM' Dockerfile
FROM node:22 AS base FROM base AS client-base FROM client-base AS client-dev FROM client-base AS client-build FROM base AS sqlite3-build FROM base AS backend-dev FROM backend-dev AS test FROM base AS final
It adds stages you didn’t need here:
basesets the base image and working directory once, and every other stage startsFROM base. A stage can build on another stage, not only copy from it.client-devandbackend-devrun the frontend and backend with live reload. The repository’scompose.yamlbuilds them withtarget:for local development.testruns the backend’s tests during the build. Thefinalstage copies a file from it, so the tests have to pass before the production image can build.sqlite3-buildcompiles thesqlite3native module in a stage of its own, so the other stages can keep npm’s install scripts turned off.
You can build any of them with --target, for example
docker build --target test . to run the tests.
What you’ve learned
- A single-stage image ships everything used to build it: compilers, dev dependencies, and a large base image.
- Each
FROMstarts a stage. Only the last stage becomes your image, andCOPY --fromis how build output gets into it. - A slim base for the final stage is usually the biggest saving. Build stages can use a full image with every tool they need.
--targetbuilds one stage on its own, and BuildKit skips stages that stage doesn’t need.
A multi-stage build controls what goes into the image, not how trustworthy
that content is. node:22-slim still has a shell, a package manager, and
Debian packages that will need updates. To go further, with a minimal,
continuously patched base and no shell at all, see
Build your application on a trusted foundation.
Clean up
Remove the container and the two images you only built for comparison. Keep
todo-app, if you want to share it next:
Host machine
docker rm -f todo
todo
docker image rm todo-app:single-stage
Untagged: todo-app:single-stage Deleted: sha256:320b665e0145b2d32fe3c819dd75f7b378488d6561d83724fe86c95cba0898c9
docker image rm todo-app:client-build
Untagged: todo-app:client-build Deleted: sha256:432c9788cfce33c3c53ced431767d43f7023c9cde65f4fe4977345f9198144ff
Next
Your image is small and serves the whole app. Next, put it somewhere other people and other machines can pull it from: Share your image on Docker Hub.
Part of a learning path
This works on its own and is also included in the paths below. Open a path to see the complete route.
Learning path
Build and share your own images
Step 3 of 4
- Step 1: Write your first Dockerfile
- Step 2: How image layers and the build cache work
- Step 3: Shrink your image with multi-stage builds (this page)
- Step 4: Share your image on Docker Hub
Table of contents
