Guide

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 --from to move only the build output into the final image
  • Compare image sizes with docker image ls and confirm what was left out
  • Build a single stage with --target to inspect or debug it

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

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-app

The 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:

Three build stages, one shipped image Two build stages on the left, both based on node:22, sit inside a dashed group labeled "build stages, not shipped". The client-build stage runs npm ci and npm run build to produce dist. The backend-build stage runs npm ci with omit dev to produce node_modules. Dashed arrows labeled COPY --from carry dist and node_modules into the final stage on the right, based on node:22-slim, which also receives package.json and src from the build context. Only the final stage becomes the todo-app image. BUILD STAGES · NOT SHIPPED client-build FROM node:22 RUN npm ci Vite, React, and all dev tools RUN npm run build → dist/ backend-build FROM node:22 RUN npm ci --omit=dev production packages only, gcc and Python on hand if needed final stage FROM node:22-slim src/static/ from client-build node_modules/ from backend-build package.json, src/ from your build context todo-app COPY --from
  • client-build installs the frontend’s packages, including Vite, and runs npm run build to produce static files in dist/.
  • backend-build installs the backend’s packages with --omit=dev, so jest, nodemon, and prettier are never installed. It uses the full node:22 image because some packages, such as sqlite3, 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. AS names the stage, so later instructions can refer to it. Each build stage copies its package.json and lockfile before its source, so a code change doesn’t rerun npm ci.
  • COPY --from=backend-build /usr/local/app/node_modules ./node_modules. Without --from, COPY reads from your build context, the files on your machine. With it, COPY reads 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 it final is 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:

  1. Vite: only client-build. It’s a frontend devDependency, and the final stage never copies that stage’s node_modules.
  2. The compiled frontend in dist/: client-build, where it’s created, and the final stage, which copies it to src/static.
  3. Express and sqlite3: backend-build, which installs them, and the final stage, which copies node_modules across.
  4. jest: no stage. npm ci --omit=dev skips devDependencies.
  5. gcc and Python: both build stages, which use the full node:22 image. Not the final stage, which uses node:22-slim.
  6. 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:

  • base sets the base image and working directory once, and every other stage starts FROM base. A stage can build on another stage, not only copy from it.
  • client-dev and backend-dev run the frontend and backend with live reload. The repository’s compose.yaml builds them with target: for local development.
  • test runs the backend’s tests during the build. The final stage copies a file from it, so the tests have to pass before the production image can build.
  • sqlite3-build compiles the sqlite3 native 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 FROM starts a stage. Only the last stage becomes your image, and COPY --from is 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.
  • --target builds 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.

Learning paths

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.