Two images sit side by side on my server, both produced by a multi-stage Dockerfile. A Next.js application: 227 MB. A Strapi CMS: 1.03 GB. Next to them, the two official .NET base images that follow the same pattern: SDK 940 MB, runtime 230 MB. Measured on 3 September 2026 with docker image inspect --format '{{.Size}}'. That is the unpacked size on disk, not the download size from the registry.
Updated 19 October 2026: Expanded with re-measured sizes of my own images, the lock file incident with npm 10, and how Docker layers behave when packages are reinstalled.
Updated 7 October 2026: Added a note on the Trivy supply-chain compromise of March 2026: pin the GitHub Action by commit SHA and the image by digest, and do not use floating tags.
The spread between 227 MB and 1.03 GB is the actual point. Both are multi-stage builds. The pattern on its own guarantees nothing about size; it only guarantees that you get to decide what goes in. For scale: the node:26-alpine base image alone weighs 174 MB on the same host. Roughly three quarters of the Next.js application's 227 MB is the runtime itself.
Build stage and runtime stage
A multi-stage Dockerfile contains several FROM instructions. Each one opens a new stage, named via AS <name>. COPY --from=<name> pulls selected artefacts out of an earlier stage; anything you don't copy never reaches the result.
Source code
|
| docker build
v
+---------------------------+
| Build stage | SDK + all dependencies
| sdk:10.0 / node:26- | npm ci / dotnet restore
| alpine | npm run build / dotnet publish
+---------------------------+
|
| COPY --from=build
v
+---------------------------+
| Runtime stage | runtime artefacts only
| aspnet:10.0 / node:26- | no compiler, no SDK
| alpine | no dev dependencies
+---------------------------+
|
v
Final image
docker images only shows the output of the last stage. The pattern pays off wherever build tooling and runtime tooling differ: always for .NET, Go and Rust, equally for TypeScript projects, and for plain JavaScript as soon as linters and test frameworks should stay out of the production image.
Node.js: ordering inside the Dockerfile decides your cache
The Dockerfile behind my Next.js application uses four stages: a shared base and, on top of it, deps, builder and runner. Trimmed to the essentials:
FROM node:26-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:26-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
The critical line is the second one in the deps stage: only package.json and package-lock.json are copied before npm ci runs. Change a TypeScript file without touching a dependency and that layer stays cached, so the build jumps straight to compilation. Copy the source first and you invalidate the install layer on every edit.
A .dockerignore in the same directory is not optional. Without it, Docker ships node_modules and the entire build output to the daemon on every invocation:
node_modules
.next
npm-debug.log*
.env*
coverage
.git
The failure that turned both pipelines red
On 23 June 2026 both GitHub Actions pipelines in my Next.js project were red, for two different reasons. The CI pipeline failed at npm ci, the image pipeline at the downstream vulnerability scan. The first failure is the more instructive one.
For this article I pulled the lock file as it stood back then out of the git history and ran npm ci against it once more inside the build image. The head of the output:
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: @parcel/watcher-android-arm64@2.5.6 from lock file
npm error Missing: @parcel/watcher-darwin-arm64@2.5.6 from lock file
npm error Missing: @parcel/watcher-darwin-x64@2.5.6 from lock file
[... 161 more "Missing:" lines ...]
npm error Missing: @esbuild/win32-x64@0.28.1 from lock file
165 lines of that, spread across @parcel/watcher-*, @tailwindcss/oxide-*, @esbuild/*, lightningcss-*, @img/sharp-*, @swc/core-* and @unrs/resolver-binding-*.
The disorienting part: nothing needed those packages. @esbuild/darwin-arm64 is the macOS build of a bundler, @esbuild/win32-x64 the Windows one, and the build was running in a Linux container. npm refused anyway.
The cause was how the lock file had been produced: re-resolved under npm 10, according to my notes from the time with npm install --package-lock-only, the convenient route when you want to bump a version without installing anything locally. That route dropped the platform-specific optionalDependencies. Meanwhile the build image had moved to node:26-alpine with npm 11, and npm 11 requires that every declared optional platform variant is present in the lock file for npm ci, including the ones it will never install on the current platform. If they are missing, the lock file counts as out of sync and npm ci aborts rather than resolving anything. Which is precisely what npm ci is for.
Exactly what was missing is countable across the two git revisions. The broken revision held 597 package entries, the repaired one 761. Of each family of platform-specific packages, exactly one variant survived in the broken file: @esbuild/linux-arm64, @tailwindcss/oxide-linux-arm64-musl, @parcel/watcher-linux-arm64-musl, precisely the ones for the machine the file was generated on. The repaired lock file (lockfileVersion 3) carries 74 @esbuild/ entries and 12 @tailwindcss/oxide-* instead.
The fix had two halves. First: regenerate the lock file with a real npm install inside the build image. Second: the same npm major version everywhere. The build image is node:26-alpine with npm 11.16.0, the CI workflow moved its setup-node step from Node 22 to Node 26 in the same commit, and the host runs npm 11.19.0. Three environments, one major version, one lock file.
I had tried it the other way round first, and that is the part I remember. Less than twenty minutes earlier the same day I had deliberately re-resolved the lock file with npm 10, so that CI would go green again; CI was still on Node 22 back then. That exact revision then broke the Docker build under npm 11. One lock file cannot serve two npm majors at once. Repairing one end without moving the others just relocates the failure.
A note from re-checking: the trigger is version-dependent. In a throwaway container with npm 10.9.8 I generated a lock file via --package-lock-only, and that late npm 10 does write the platform variants, so npm ci under npm 11 passes. Which npm 10 patch release produced the broken file back then is not documented. So if the trap doesn't catch you today, that doesn't mean your workflow is safe. It means your npm version happens not to trigger it.
The rule I have followed since is therefore not "never use --package-lock-only". It is: the lock file gets generated with the build image's npm, and host, CI and build image all run the same npm major. As long as that holds, even docker run --rm -v $PWD:/app -w /app node:26-alpine npm install <package> --package-lock-only is harmless. That is still how I bump versions. Let one of the three environments drift to a different major and the lock file drifts with it.
Why rm -rf npm does not make the image smaller
Since that same day the runner stage carries a line that looks like an exercise in frugality:
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
The trigger was the Trivy scan in the image pipeline, configured with severity: HIGH,CRITICAL, ignore-unfixed: true and exit-code: 1: a single finding that has a fix available fails the job. The finding did not come from the application. The npm CLI shipped inside node:26-alpine vendors its own copy of undici at version 6.26.0, while Node 26 itself uses 8.5.0. Checked inside the image: cat /usr/local/lib/node_modules/npm/node_modules/undici/package.json reports 6.26.0, node -e "console.log(process.versions.undici)" reports 8.5.0. The runtime never calls npm, only node server.js and node scripts/migrate.mjs. The tool was pure attack surface.
What I noted at the time as a side benefit, "image scan-clean + smaller", is half right. I put the two images from back then side by side: the state before is 227,376,409 bytes, the state after is exactly the same. To the byte, with different image IDs. docker history explains it: the RUN rm -rf line produces its own layer of 0 B. The npm files live in the base layer and stay there; the delete only writes a marker on top that hides them in the assembled filesystem. Inside the container npm is gone (ls /usr/local/bin/npm returns "No such file or directory", and /usr/local/lib/node_modules shrinks from 17.8 MB to 8 KB), but not on disk.
For the scanner that is enough, because Trivy reads the assembled filesystem. For size it achieves nothing. Saving real bytes means leaving the file out of the stage that creates it, or picking a leaner base image. It is the same mechanism behind the fact that container updates cannot be solved by deleting things in a later layer.
.NET: build with the SDK, ship the runtime
For an ASP.NET Core application the pattern is identical; only the base images change:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApi.dll"]
*.csproj first, dotnet restore on its own: the same caching logic as npm ci. --no-restore stops dotnet publish from resolving the packages a second time.
Two details I looked up in the local image rather than copying from the documentation. docker image inspect mcr.microsoft.com/dotnet/aspnet:10.0 shows ASPNETCORE_HTTP_PORTS=8080 and APP_UID=1654 in the environment; the same image's /etc/passwd contains app:x:1654:1654::/home/app:/bin/sh. So the port is already set in the base image, the non-root user already exists, and USER app works without your own adduser. On size: SDK 940 MB against runtime 230 MB. One caveat belongs with that comparison: the SDK image is present here as the arm64 variant, the runtime image as amd64. The order of magnitude holds, but the two numbers are not strictly comparable. All the more reason to build explicitly for the target platform in a CI pipeline with GitHub Actions and GHCR.
When slimming down backfires
Back to the 1.03 GB image. That is my Strapi CMS, and the size is a decision rather than an oversight. The Dockerfile says so:
# NOTE: keep the full node_modules tree in the runtime image, Strapi's
# admin/runtime resolves packages that a --prod re-install would strip.
FROM node:24-alpine@sha256:156b55f9... AS runner
COPY --from=builder --chown=node:node /app /app
USER node
Trying to tidy up in the runtime stage with a production-only reinstall removed @strapi/typescript-utils, a dev dependency the framework still needs at startup. The container stopped booting and failed on a tsconfig extends. According to my notes from the time the image did not even get smaller in the process but larger; I cannot re-measure that, the image is long gone. It is plausible from the same mechanism as the deleted npm: the reinstall arrives as an additional layer while the old tree sits untouched in the layer below.
The runner therefore copies the whole tree on purpose and runs as USER node. One detail that surfaces during that switch: a freshly created volume is owned by root whenever the image holds no matching path whose ownership Docker could adopt. If the process then runs as uid 1000, the first write fails with EACCES, which is exactly what my notes record as a deploy landmine, with the chown as the remedy. Today the uploads volume is owned by 1000:1000, matching the id output inside the container (uid=1000(node)). A separate step before the deploy, not something Docker handles for you.
What I check before the next build
Four questions that came out of the failures above:
- Is the same package-manager major version in play everywhere? Host, CI and build image against one lock file, or it drifts apart again.
- Which tool produced the lock file? Not the one on your laptop, but the one from the build image. Otherwise the file ends up holding exactly your workstation's platform variants and no others.
- Does the runtime actually invoke the tool I'm shipping? If not, take it out: for the scanner, not for the bytes.
- What happens when I clean up in the runtime stage? Use
docker historyto check whether the layer saves anything. Very often it is 0 B.
Trivy was the target of a supply-chain compromise in March 2026, so pin the GitHub Action by commit SHA and the image by digest instead of using floating tags, see advisory GHSA-69fq-xp46-6x23.
How the finished images then behave together (dependencies, networks and health checks) is covered in the article on Docker healthchecks and restart_policy. If you are containerising an existing .NET application and want someone who has already made these mistakes in person, that is part of my .NET development work.
Official documentation: Docker Multi-Stage Builds, Docker Build Best Practices, .NET Container Images.
Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the legal notice).