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.
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.
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.
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."
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.
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.
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
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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?
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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.