← Open in the full interactive course (progress tracking, search & more)

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.

Get this exactly right: A container shares the host's kernel. It is not a separate operating system running its own kernel — that's what a virtual machine does. This is a real architectural distinction, not a marketing simplification, and it's the reason containers are dramatically lighter-weight than VMs.

Virtual Machine

Container

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

VM STACK vs. CONTAINER STACK
VM Stack
Hardware → Host OS → Hypervisor → Guest OS (per VM) → App
Container Stack
Hardware → Host OS (shared kernel) → Container Runtime → App

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.

MULTI-STAGE BUILD — STEP BY STEP
1. BUILD STAGE — START FROM THE FULL SDK IMAGE
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
2. RESTORE AND PUBLISH INSIDE THE BUILD STAGE
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
3. FINAL STAGE — START FRESH FROM A SMALL RUNTIME-ONLY IMAGE
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
4. COPY ONLY THE PUBLISHED OUTPUT FROM THE BUILD STAGE
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
Why this matters: The .NET SDK image is significantly larger than the ASP.NET runtime image, largely because it carries the full compiler toolchain and build tooling. Shipping that into production would be wasteful (a much bigger image to pull and store) and a needlessly larger attack surface (compilers and build tooling sitting in a production container that will never use them). The final stage should contain only what's needed to run the app.

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

An Apartment, Not a Detached House

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:

WHY INSTRUCTION ORDER MATTERS FOR CACHING
ORDERED CORRECTLY — .csproj COPIED BEFORE SOURCE
ORDERED CARELESSLY — EVERYTHING COPIED AT ONCE, THEN RESTORED

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?

Rule of thumb: If the final stage of your Dockerfile doesn't start with an aspnet or runtime base image, ask why — shipping the SDK to production is a red flag worth catching in review.

Mental Model

Container = an isolated process, sharing the host's kernel
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.