Shrink Docker images with multi-stage builds

Written by Furi10 min read

On this page

You will build a small TypeScript API into a multi-stage Docker image with a distroless runtime that runs as a non-root user, then compare its size with a single-stage baseline. The guide is for developers who ship Node.js services in containers.

Prerequisites#

  • Docker Engine 29 (29.9.2 is current) or a current Docker Desktop. BuildKit is the default builder for both.
  • Node.js 24 and npm on your machine, to create the lockfile.
  • curl for testing the API.

Check that the Buildx plugin is installed:

bash
docker buildx version

Why images get big#

Most oversized images come from a few habits:

  • The base image is a full OS. node:24 includes compilers, headers, and many Debian packages your app never calls.
  • Build tools ship to production. TypeScript, type definitions, and test runners end up next to your compiled code.
  • The build context holds more than the app needs. COPY . . pulls in node_modules from your laptop, .git, logs, and sometimes .env files.
  • Deleting a file does not shrink the image. A file created in one RUN step still takes space, even if a later step deletes it.

A .dockerignore file trims the build context. Multi-stage builds deal with the other three. Each FROM starts a new stage, and only the files you copy into the last stage end up in the image.

1. Create the sample app#

Scaffold the project#

bash
mkdir tiny-api && cd tiny-api
npm init -y
npm install express@5
npm install --save-dev typescript@7 @types/node@24 @types/express@5
npm pkg set type=module scripts.build=tsc scripts.start="node dist/server.js"
mkdir src

Add the server#

Create src/server.ts:

ts
1import express from "express";
2
3const app = express();
4const port = Number(process.env.PORT ?? 3000);
5
6app.get("/health", (_req, res) => {
7  res.json({ status: "ok" });
8});
9
10const server = app.listen(port, () => {
11  console.log(`listening on port ${port}`);
12});
13
14// Node.js runs as PID 1 in the container, so handle stop signals yourself.
15function shutdown() {
16  server.close(() => process.exit(0));
17}
18process.on("SIGTERM", shutdown);
19process.on("SIGINT", shutdown);

Add the TypeScript config#

Create tsconfig.json. TypeScript 7 defaults types to an empty list and rootDir to the project root, so set both:

json
1{
2  "compilerOptions": {
3    "target": "es2024",
4    "module": "nodenext",
5    "rootDir": "src",
6    "outDir": "dist",
7    "types": ["node"],
8    "strict": true,
9    "skipLibCheck": true
10  },
11  "include": ["src"]
12}

Build and run it once on your host:

bash
npm run build
npm start

2. Build a single-stage baseline#

Create Dockerfile.single. This is a common starting point:

docker
FROM node:24
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

Build it:

bash
docker build -f Dockerfile.single -t tiny-api:single .

This image contains the full node:24 base, every devDependency, the npm cache, and your host node_modules. It also starts the app through npm. The Node.js Docker team recommends running node directly, so SIGTERM and SIGINT reach your process instead of npm.

3. Add a .dockerignore file#

Create .dockerignore in the project root. The build client removes matching files from the context before it sends the context to the builder:

ini
node_modules
dist
.git
*.log
.env*

Never copy host node_modules into a Linux image. Packages with native or platform-specific binaries, such as TypeScript 7, install different files on macOS and Linux.

4. Split the build into stages#

Create Dockerfile next to Dockerfile.single:

docker
1# syntax=docker/dockerfile:1
2
3FROM node:24-trixie-slim AS build
4WORKDIR /app
5COPY package.json package-lock.json ./
6RUN --mount=type=cache,target=/root/.npm \
7    npm ci
8COPY tsconfig.json ./
9COPY src ./src
10RUN npm run build
11
12FROM node:24-trixie-slim AS prod-deps
13WORKDIR /app
14COPY package.json package-lock.json ./
15RUN --mount=type=cache,target=/root/.npm \
16    npm ci --omit=dev
17
18FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runtime
19WORKDIR /app
20ENV NODE_ENV=production
21COPY --from=prod-deps /app/node_modules ./node_modules
22COPY --from=build /app/dist ./dist
23COPY package.json ./
24USER 65532:65532
25EXPOSE 3000
26CMD ["dist/server.js"]

The build stage#

FROM node:24-trixie-slim AS build names the stage. It installs all dependencies, including TypeScript, and compiles src to dist. Nothing from this stage reaches the final image unless a later stage copies it.

The production dependencies stage#

prod-deps runs npm ci --omit=dev. It produces a node_modules folder with only Express and its dependencies. Because this is a separate stage, devDependencies stay out of the image without any step that deletes files afterward.

The runtime stage#

COPY --from=<stage> copies files from an earlier stage. The runtime stage copies three things: production node_modules, compiled dist, and package.json. Node.js reads the type field in package.json to load dist/server.js as an ES module.

The distroless Node.js image sets its entrypoint to /nodejs/bin/node. That is why CMD holds only the script path. CMD uses exec form because the image has no shell.

To debug one stage, build only up to it with --target:

bash
docker build --target build -t tiny-api:build .
docker run --rm -it tiny-api:build bash

With BuildKit, --target skips stages the target does not depend on.

5. Choose a runtime base#

The table lists compressed linux/amd64 sizes from Docker Hub and the gcr.io registry, checked on October 22, 2026. Images on disk are larger.

ImageCompressed sizeShellPackage manager
node:24about 410 MByesapt, npm
node:24-trixie-slimabout 83 MByesapt, npm
node:24-alpineabout 63 MByesapk, npm
gcr.io/distroless/nodejs24-debian13:nonrootabout 56 MBnonone
gcr.io/distroless/static-debian13:nonrootunder 1 MBnonone

Each runtime base has a cost:

  • Distroless is only a little smaller than Alpine. Its main advantage is what it leaves out. It has no shell, no npm, and no package manager for an attacker or a scanner to find. The cost is harder debugging. You cannot docker exec into it with sh, and HEALTHCHECK scripts that call curl will not work.
  • Alpine uses musl libc instead of glibc. Pure JavaScript packages work. Native addons and prebuilt binaries need musl builds. If you pick Alpine, use node:24-alpine for every stage, not only the runtime.
  • Slim is the most compatible choice. It keeps glibc, a shell, and apt, but the image is larger.

Match the Debian release across stages. trixie is Debian 13, which matches nodejs24-debian13. Native modules compiled in the build stage then link against the same glibc at runtime. For Node.js 24, the plain node:24 and node:24-slim tags still point to Debian 12 (bookworm), so name trixie in the tag.

For an Alpine build, change the build and prod-deps stages to FROM node:24-alpine too. The official image ships a node user with UID 1000. The runtime stage below sets that user by number, as step 7 recommends:

docker
1FROM node:24-alpine AS runtime
2WORKDIR /app
3ENV NODE_ENV=production
4COPY --from=prod-deps /app/node_modules ./node_modules
5COPY --from=build /app/dist ./dist
6COPY package.json ./
7USER 1000:1000
8EXPOSE 3000
9CMD ["node", "dist/server.js"]

6. Order layers for the cache#

When a file that a layer uses changes, Docker rebuilds that layer and every layer after it. Put slow steps whose inputs rarely change first, and steps whose inputs change often last.

The build stage copies package.json and package-lock.json before src. When you edit src/server.ts, Docker reuses the cached npm ci layer and reruns only npm run build.

The RUN --mount=type=cache,target=/root/.npm flag adds a second cache. When the lockfile changes, the layer still rebuilds, but npm reads packages it already downloaded from the mounted cache. The cache directory never becomes part of the image. npm caches to ~/.npm by default, and the build stages run as root, so the target is /root/.npm.

The # syntax=docker/dockerfile:1 line tells BuildKit to fetch the latest stable release of the version 1 Dockerfile syntax.

7. Run as a non-root user#

The :nonroot distroless tag already sets the user to UID 65532. The USER 65532:65532 line makes that explicit and keeps the setting if you change the base image. Kubernetes can only verify runAsNonRoot against a numeric user, so prefer numbers over names.

Files copied without --chown belong to UID 0. The non-root process can read them but cannot change its own code or dependencies. Leave that ownership in place. If the app must write files, mount a volume for that path at runtime. If a copied path must belong to the app user, add --chown=65532:65532 to that COPY line.

Verify it works#

Build the multi-stage image and start it:

bash
docker build -t tiny-api:multi .
docker run --rm -d -p 3000:3000 --name tiny-api tiny-api:multi

Call the health endpoint:

bash
curl http://localhost:3000/health

You should see:

json
{"status":"ok"}

Stop the container. It exits right away because the app handles SIGTERM:

bash
docker stop tiny-api

Compare the two images:

bash
docker images tiny-api

Docker 29 shows a collapsed tree view by default, with DISK USAGE and CONTENT SIZE columns. Compare the same column across tags. To get a single size column instead, pass a format:

bash
docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" tiny-api

Show the size of each layer:

bash
docker history tiny-api:single
docker history --format "table {{.Size}}\t{{.CreatedBy}}" tiny-api:multi

In the single-stage image, the base layers and the npm install layer take most of the space. In the multi-stage image, the largest layer you add should be node_modules.

Confirm the image user:

bash
docker image inspect --format '{{.Config.User}}' tiny-api:multi

The output is 65532:65532.

Troubleshooting#

npm ci fails without a lockfile#

npm ci requires package-lock.json and exits with an error if the lockfile does not match package.json. Commit the lockfile. Check that .dockerignore does not exclude it. Run npm install on the host after changing dependencies.

No shell in the distroless image#

docker exec -it tiny-api sh fails with executable file not found in $PATH, because distroless has no shell. For a debugging session, change the runtime tag to :debug-nonroot, which adds a BusyBox shell. Rebuild, then override the entrypoint, because it points at Node.js:

bash
docker build -t tiny-api:debug .
docker run --rm -it --entrypoint=sh tiny-api:debug

Switch the tag back before you deploy. You can also inspect the build stage with --target build.

Native modules crash at startup#

Errors about missing shared libraries or an invalid ELF header mean the binary was built for another libc or platform. Keep the build stage and the runtime on the same family: trixie with debian13, or Alpine everywhere. Never copy host node_modules.

Cache mounts do not help in CI#

BuildKit keeps cache mounts in the builder's local storage. Exported build cache does not include their contents, so on a fresh CI runner the npm cache mount starts empty. Exported layer cache still saves the npm ci layer when the lockfile has not changed. For GitHub Actions, the Docker docs on cache management (opens in a new tab) describe a workaround for cache mounts.

Next steps#

The same pattern works for Go. There the runtime can be the static distroless image, which is under 1 MB compressed. With CGO_ENABLED=0, the binary has no libc dependency:

docker
1# syntax=docker/dockerfile:1
2
3FROM golang:1.27-trixie AS build
4WORKDIR /src
5COPY . .
6RUN --mount=type=cache,target=/go/pkg/mod \
7    --mount=type=cache,target=/root/.cache/go-build \
8    CGO_ENABLED=0 go build -o /out/app .
9
10FROM gcr.io/distroless/static-debian13:nonroot
11COPY --from=build /out/app /app
12USER 65532:65532
13ENTRYPOINT ["/app"]

Keep reading

All guides