Docker for .NET Developers: Multi-Stage Dockerfile Guide
A container isn't a tiny virtual machine. It's your app, wrapped just tightly enough to carry its own dependencies with it — while still sharing the same kernel as everything around it.
"It works on my machine." Every developer has said it, and every developer has watched it fail to be reassuring. The app runs fine locally, then breaks in staging because staging has a different .NET runtime patch version, or a missing native library, or an environment variable nobody documented. Lesson 297 talked about keeping configuration separate from the build so the same artifact runs everywhere — but that only works if "the same artifact" also carries the same runtime and dependencies with it, everywhere it goes.
That's the problem containers solve. Not by virtualizing an entire computer — that's what a virtual machine does, and it's heavier than this needs to be — but by packaging your app together with exactly the runtime and dependencies it needs, in a form that runs identically on a laptop, in a CI pipeline, and in production.
In this lesson, you'll learn precisely what a container is (and precisely how it differs from a VM), why that distinction matters for .NET deployment specifically, and how to write a correct, efficient, multi-stage Dockerfile for a .NET application.
What Is It?
The Simple Explanation
A container is your application, plus everything it needs to run — the .NET runtime, libraries, config defaults — bundled together into one package that behaves the same way no matter what machine it's started on.
The Technical Definition
A container is a lightweight, isolated process running on a host machine. It has its own filesystem view, its own network interface, and its own set of installed dependencies — all isolated from other containers and from the host — but it runs as an ordinary process using the host operating system's own kernel. This last part is the single most important, and most commonly misunderstood, fact about containers, and it's worth stating precisely before going any further.
Virtual Machine
- Virtualizes an entire computer, including its own separate kernel
- A hypervisor sits between the VM and the physical hardware
- Boots a full guest OS — typically takes seconds to minutes
- Each VM carries the overhead of a complete OS install
- Strong isolation — a fully separate kernel per VM
Container
- Shares the host machine's kernel — no separate kernel per container
- The container runtime (e.g. Docker) manages isolated processes directly on the host
- Starts as fast as starting a process — typically well under a second
- Only carries the app and its dependencies, not a whole OS
- Process-level isolation, not full kernel-level virtualization
Why Does It Exist?
The Problem — "The Machine" Is Never Really the Same Twice
Before containers, deploying a .NET app meant hoping the target machine already had the right .NET runtime version installed, the right native libraries, the right OS patches, and no conflicting software fighting for the same resources. A developer's laptop, a CI runner, and a production server are, in practice, three different "machines" — three different opportunities for "works here, breaks there."
The Solution — Package the Relevant Parts of "The Machine" With the App
A container packages the runtime, the dependencies, and the app's own configuration defaults together, so "the machine" the app actually runs on is defined by the container image, not by whatever happens to be installed on the host. The same container image runs identically on the developer's laptop, in the CI pipeline, and in production — because in every one of those cases, it's genuinely running the same bundled runtime and dependencies, just on top of a different host kernel underneath.
Big Picture
Notice what's missing from the container stack: there's no per-container guest OS. Every container on a host shares that one host kernel underneath it. That's the entire source of the weight difference — a VM's guest OS has to boot, allocate its own memory for OS-level bookkeeping, and run its own kernel processes; a container just starts as a regular, isolated process on a kernel that's already running.
How It Works — The Multi-Stage Dockerfile Pattern
A Dockerfile is a script of instructions describing how to build a container image. For .NET apps, the real, standard, Microsoft-recommended pattern is a multi-stage build: one stage does the compiling, using the full SDK; a second, separate stage runs the compiled output, using only a lightweight runtime image. Only the compiled output crosses from the first stage into the second.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
- The SDK image contains the compiler, MSBuild, NuGet tooling — everything needed to build the app, but far more than needed to run it.
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
- dotnet publish produces the compiled DLLs and runtime config — the only files the final image actually needs.
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
- No compiler, no MSBuild, no build tooling — just enough to run a published app. Use aspnet for web apps, or the smaller dotnet/runtime image for non-web console/worker apps that don't need ASP.NET Core.
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
- The --from=build flag pulls files from the earlier stage. Everything else from the build stage — the SDK, intermediate build files — is discarded and never becomes part of the final image.
Simple Example — A Complete Multi-Stage Dockerfile
Putting the whole pattern together for a simple web API:
# ── Stage 1: Build ──
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy only the project file first — see "image layering" below for why
COPY ["OrderApi.csproj", "."]
RUN dotnet restore "OrderApi.csproj"
# Now copy the rest of the source and publish
COPY . .
RUN dotnet publish "OrderApi.csproj" -c Release -o /app/publish --no-restore
# ── Stage 2: Final runtime image ──
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "OrderApi.dll"]Meaning: Building this Dockerfile produces one final image, based on the small aspnet runtime, containing nothing but the published app. The entire SDK — everything used in Stage 1 — never appears in that final image at all; Docker simply discards Stage 1's filesystem once COPY --from=build has pulled out the one directory it needed.
Real-World Example
A team building an order-processing API containerizes it with exactly this pattern. Their CI pipeline runs docker build on every commit to main, producing one image tagged with the commit SHA. That image — built once, with the multi-stage pattern keeping it small — gets pushed to a container registry and is the same image that later gets deployed to staging, then promoted unmodified to production (exactly the Twelve-Factor build-once, promote-everywhere flow from Lesson 297). Because the image only carries the ASP.NET runtime and the app's own DLLs, it pulls quickly on every deploy and gives an attacker who somehow got shell access far less to work with than a full SDK image would.
Analogy
A virtual machine is like a detached house — it has its own complete plumbing, its own electrical system, its own foundation, entirely separate from the house next door. Building one from scratch is expensive and slow, and most of that infrastructure is duplicated needlessly between houses that could easily have shared it.
A container is like an apartment in a shared building. Every apartment has its own front door, its own private rooms, its own isolated space — you can't wander into your neighbor's apartment — but the building's foundation, plumbing risers, and electrical mains are shared infrastructure underneath everyone. That shared foundation is the host kernel. Building a new apartment (starting a new container) is fast, because the expensive shared infrastructure is already there — you're not pouring a new foundation every time.
Under the Hood — Image Layers and the Build Cache
A container image isn't one monolithic blob — it's a stack of layers, one per Dockerfile instruction that changes the filesystem (COPY, RUN, etc.). Docker caches each layer, and if a given instruction and everything before it are unchanged since the last build, Docker reuses the cached layer instead of re-running that instruction.
This is exactly why the example Dockerfile above copies only the .csproj file and runs dotnet restore before copying the rest of the source code:
- Application source code changes on nearly every commit — that's the whole point of writing code.
- NuGet package references in the .csproj change far less often.
- Copying just the .csproj and restoring first means: as long as the .csproj hasn't changed, Docker reuses the cached, already-restored layer on every subsequent build — skipping the NuGet restore entirely, even though the source code changed.
- If COPY . . happens before dotnet restore, then any source file change invalidates that layer — including the restore step that follows it.
- Result: a full NuGet restore re-runs on every single build, even for a one-line code change that touched no dependencies at all.
This ordering trick doesn't change what the final image contains — it only changes how much unnecessary work Docker redoes on every build. For a project with many NuGet dependencies, this is often the difference between a build that takes seconds and one that takes minutes, every single time.
Common Confusion
1. "A container is just a lightweight VM" — no, it's a different mechanism entirely
This phrase gets repeated so often it's worth actively unlearning. A VM virtualizes hardware and runs a genuinely separate guest kernel; a container is an isolated process sharing the host's one kernel. The word "lightweight" is doing a lot of hiding here — it's not just a smaller VM, it's a fundamentally different isolation mechanism, which is why it's so much lighter.
2. "The SDK image and the runtime image are just different sizes of the same thing" — no, they serve different jobs entirely
The SDK image is a build tool — it exists to compile and publish. The aspnet/runtime images are execution environments — they exist only to run already-compiled code. Using the SDK image for your final, deployed container isn't just "wasting some disk space" — it means shipping compiler and build tooling into a production environment that will never legitimately use it.
Common Mistakes
Mistake 1 — Using a single-stage Dockerfile based on the SDK image
FROM mcr.microsoft.com/dotnet/sdk:10.0 as the only stage, with the app run directly from it — simple to write, but ships the entire build toolchain into production. Always use the two-stage pattern: build with the SDK, run from aspnet or runtime.
Mistake 2 — Copying all source before restoring dependencies
COPY . . followed by RUN dotnet restore means the restore layer gets invalidated on every source change, even trivial ones. Copy the .csproj alone, restore, then copy the rest of the source — as shown in the Simple Example above.
Mistake 3 — Reaching for a VM out of habit when a container would do
Provisioning a full VM per service because "that's how deployment has always worked" — when the workload is just "run this .NET app," a full guest kernel adds boot time, resource overhead, and operational cost with no corresponding benefit. Use containers for ordinary application workloads; reserve VMs for cases that genuinely need a separate kernel or full OS-level isolation.
When Should I Use It?
- Any .NET web API, worker service, or background service you plan to deploy to a cloud platform or orchestrator — containers are the default deployment unit across this entire Part.
- Use mcr.microsoft.com/dotnet/aspnet as the final-stage base for web apps (ASP.NET Core APIs, MVC apps, Blazor Server), and mcr.microsoft.com/dotnet/runtime for non-web workloads like console apps or worker services that don't need ASP.NET Core at all — it's smaller still.
- A VM (not a container) is still the right tool when a workload genuinely needs a separate kernel, custom OS-level configuration, or the strongest possible isolation boundary between tenants — that's a real, if less common, requirement.
Mental Model
VM = a whole separate computer, with its own kernel
SDK image = for building (Stage 1) — never ships
aspnet / runtime image = for running (Stage 2) — this is what ships
Remember: copy the .csproj and restore before copying the rest of the source — that ordering is what makes the Docker build cache actually work for you.
Key Takeaway
- A container is a lightweight, isolated process sharing the host OS's kernel — not a full virtual machine with its own separate kernel. This is a real, fundamental distinction that explains why containers are so much lighter and faster to start.
- Containers solve "works on my machine" by packaging the relevant parts of "the machine" — runtime, dependencies, config defaults — alongside the app itself, so the same image runs identically everywhere.
- The standard .NET Dockerfile pattern is a multi-stage build: a build stage on the full dotnet/sdk image, and a final stage on the much smaller dotnet/aspnet (web) or dotnet/runtime (non-web) image, copying across only the published output.
- Never ship the SDK into production — it's wasteful and a needlessly larger attack surface. The final image should contain only what's needed to run the app.
- Order Dockerfile instructions so infrequently-changing steps (like restoring NuGet packages from a copied .csproj) come before frequently-changing ones (copying the rest of the source) — Docker's build cache rewards that ordering with much faster rebuilds.
Check Your Understanding
You've seen what a container actually is, how it differs from a VM, and how to write a proper multi-stage .NET Dockerfile. Let's check it clicked.
1. What is the precise, fundamental difference between a container and a virtual machine?
Show answer
Correct: B
Why B is correct: This is the real, load-bearing distinction. A container is an isolated process on the host's existing kernel; a VM boots and runs a completely separate guest kernel on top of a hypervisor. That's what makes containers so much lighter-weight.
Why A is incorrect: Memory allocation is configurable for both and isn't the defining difference — the kernel-sharing architecture is.
Why C is incorrect: Both VMs and containers can run various operating systems/distributions, subject to their own constraints — OS choice isn't the distinguishing factor here.
Why D is incorrect: The terms describe genuinely different isolation mechanisms with real architectural and performance consequences.
Reinforcement: Shared kernel (container) vs. separate kernel (VM) is the fact to hold onto.
2. In a correct multi-stage .NET Dockerfile, what should the final stage's base image be for a web API?
Show answer
Correct: B
Why B is correct: The whole point of the multi-stage pattern is that the final stage only needs to run the app, not build it — aspnet gives exactly that for web applications, keeping the shipped image small.
Why A is incorrect: The SDK image ships compiler and build tooling that a running production app never needs — that's precisely the waste the multi-stage pattern exists to avoid.
Why C is incorrect: Using the same (SDK) image for both stages defeats the entire purpose of separating build from run.
Why D is incorrect: This reinvents, less reliably, exactly what the official aspnet image already provides, pre-built and Microsoft-maintained.
Reinforcement: Build with the SDK, run with aspnet (or runtime for non-web apps) — never the reverse, and never the same image for both.
3. Why does copying only the .csproj file and running dotnet restore before copying the rest of the source code speed up repeated Docker builds?
Show answer
Correct: B
Why B is correct: Docker caches layers per instruction. Isolating the rarely-changing .csproj/restore step from the frequently-changing source copy means the cached restore layer survives most ordinary code-only commits, skipping a potentially slow NuGet restore.
Why A is incorrect: The restore command itself runs at the same speed either way — what changes is whether Docker has to run it again at all.
Why C is incorrect: This ordering trick is about build speed via caching, not the size of the resulting image — that's governed by which base image and stage the final COPY pulls from.
Why D is incorrect: There's no such requirement — this is purely a caching optimization, not a correctness requirement of dotnet publish.
Reinforcement: Order Dockerfile instructions from least-frequently-changing to most-frequently-changing to get the most value out of the build cache.
4. A team ships a container built directly on mcr.microsoft.com/dotnet/sdk for production, with the app run straight from source inside it. What's the concrete downside?
Show answer
Correct: B
Why B is correct: This is exactly the waste the multi-stage pattern is designed to eliminate — a production container has no legitimate use for a compiler or MSBuild, and their presence only adds size and unnecessary attack surface.
Why A is incorrect: The officially recommended, standard pattern is multi-stage, specifically to avoid shipping the SDK.
Why C is incorrect: The SDK image can run apps just fine (it includes the runtime) — the issue is what it unnecessarily brings along with it, not that it's incapable of running anything.
Why D is incorrect: Environment variable configuration works identically regardless of which base image is used — that's unrelated to this mistake.
Reinforcement: "It works" is not the same bar as "it's the right image for production" — size and attack surface are real, separate concerns.
You now understand what a container actually is, how it differs from a VM, and how to build a proper multi-stage Dockerfile for .NET. Next: the ASP.NET-Core-specific details of running well inside one.