Skip to content
← All posts
· 5 min read·Emre Yurtbay

Multi-Stage Dockerfile: Minimal, Secure Image for Node and .NET

Build tools don't belong in production images. Multi-stage builds separate compiler from runtime and shrink images from over a gigabyte to a fraction of that size.

DockerMulti-Stage-BuildNode.js.NETDockerfileDevOpsSelf-HostingContainer

A straightforward docker build on a Node or .NET project without any optimization often produces an image well over a gigabyte — packed with SDK, compiler, dev dependencies, and temporary build artifacts that nobody needs at runtime. Multi-stage builds separate the build process from the final image: you use a full build environment only as an intermediate step, and the finished image contains nothing but the runtime artifacts. The result is a dramatically smaller image with a significantly reduced attack surface.

When Multi-Stage Builds Make Sense

Whenever there is a meaningful difference between your build tooling and your runtime tooling. For compiled languages (.NET, Go, Rust) this is always the case. For JavaScript projects with a TypeScript compilation or bundling step it applies just as well. Even plain JavaScript benefits when you want to keep dev dependencies — linters, test frameworks, build tools — out of the production image. Put simply: if you're doing more than FROM node:latest + COPY . ., multi-stage is worth considering.

Concept: Build Stage vs. Runtime Stage

A multi-stage Dockerfile contains multiple FROM statements. Each one opens a new stage that receives its own name via AS <name>. You can selectively copy from an earlier stage using COPY --from=<name> — everything else in that stage never makes it into the final image.

Source code
     |
     |  docker build
     v
+---------------------------+
|        Build Stage        |  SDK + all dependencies
|  sdk:10.0 / node:24-     |  npm ci / dotnet restore
|  alpine                   |  npm run build / dotnet publish
+---------------------------+
             |
             |  COPY --from=build
             v
+---------------------------+
|      Runtime Stage        |  runtime artifacts only
|  aspnet:10.0 /           |  no compiler, no SDK
|  node:24-alpine           |  no dev dependencies
+---------------------------+
             |
             v
       Final image

docker images shows only the result of the last stage. The build stage is used internally and then discarded.

Node.js: From One Gigabyte to Around 200 MB

The example is a TypeScript application that compiles to dist/ via npm run build. Start by adding a .dockerignore file to the project directory — without it Docker sends node_modules and the entire build output on every call, which slows things down considerably:

node_modules
dist
.env
.git
coverage
*.log

The corresponding Dockerfile:

# ---- Build Stage ----
FROM node:24-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ---- Runtime Stage ----
FROM node:24-alpine AS runtime
WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev

COPY --from=build /app/dist ./dist

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000
CMD ["node", "dist/index.js"]

Two points are critical: first, package*.json is copied before the source code so Docker can cache the npm ci layer as long as dependencies do not change. If you only modify a TypeScript file, Docker skips npm ci entirely and continues building immediately — saving minutes. Second, npm ci --omit=dev in the runtime stage installs only production dependencies; the TypeScript compiler, linter, and test frameworks are absent from the final image. The result is typically 180–250 MB instead of well over a gigabyte — the node:24-alpine base image alone is already around 160 MB.

.NET: SDK Image vs. Runtime Image

For an ASP.NET Core Minimal API, you separate the full SDK image used for building from the much smaller runtime image used for deployment. Start again with .dockerignore:

bin
obj
.git
.env
**/*.user

The Dockerfile:

# ---- Build Stage ----
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

# ---- Runtime Stage ----
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"]

The pattern mirrors Node: *.csproj comes first, dotnet restore runs in isolation and gets cached. dotnet publish -c Release compiles and optimizes; --no-restore skips a redundant restore since packages were already fetched in the layer above. The aspnet:10.0 image (Ubuntu 24.04) contains only the ASP.NET Core runtime, not the SDK — the finished image is around 230–240 MB. For even smaller images, the Chiseled variant (mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled) brings that down to around 125 MB — roughly half the size of the regular image.

Port 8080 note: Since .NET 8, ASP.NET Core listens on port 8080 by default in container environments, controlled via ASPNETCORE_HTTP_PORTS. This variable is already set in the base image, so no extra configuration is needed. USER app works because the official .NET 8+ images ship with a predefined non-root user app (UID 1654).

Replace MyApi.dll with the actual assembly name of your project (it matches the <AssemblyName> value in your .csproj file).

Three Common Pitfalls

1. Alpine and native modules. node:24-alpine is based on musl libc, not glibc. Many popular native modules now ship prebuilt musl binaries and install on Alpine without compiling — for example bcrypt (v6+), sharp (v0.33+), and sqlite3. Older versions or modules without musl prebuilds that build from source via node-gyp need apk add python3 make g++ in the build stage instead. If that is too much overhead, switch to node:24-slim (Debian-based, glibc included).

2. Forgetting .dockerignore. Without it, Docker transfers the entire build context — including node_modules or bin/obj — to the build daemon on every call. Depending on project size, this can cost seconds to minutes. The file should be in the project directory before the first docker build.

3. latest instead of pinned tags. FROM node:latest or FROM mcr.microsoft.com/dotnet/sdk:latest may deliver a different version on every rebuild. Pin to concrete tags such as node:24-alpine or sdk:10.0 to keep builds reproducible.

What Comes Next

The pattern transfers directly to other languages: Go binaries, Python wheels, Rust executables — the same logic applies everywhere. Sensible next steps include scanning the finished image for known vulnerabilities with docker scout cves or Trivy, setting up a GitHub Actions workflow that pushes the image automatically to the GitHub Container Registry (GHCR), or trying the Chiseled variant for .NET.

Official documentation: Docker Multi-Stage Builds, Docker Build Best Practices, Node.js Official Docker Images, .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 Impressum).

Discuss your project