Docker & Containers
Key Points
- Always multi-stage builds: build in SDK image, runtime in slim/chiseled image. Final image: ~100-200 MB (or <50 MB AOT).
- Modern .NET base images:
mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled(smallest),nanoserver(Windows),alpine(musl-libc; AOT pitfalls). - Run as non-root (
USER 1000:1000); read-only root FS where possible; minimal capabilities. - AOT publishing produces tiny self-contained images (~30-50 MB). Trim warnings must be resolved.
- Layering: copy
*.csprojfirst;dotnet restore; then source. Caches restore across code changes.
Concepts (deep dive)
What a container actually is
A container is just a regular Linux process — but the kernel has been told to lie to it about what it can see. Six kinds of namespaces (mount, PID, network, user, UTS, IPC) give the process its own private view of the filesystem, process tree, network stack, user IDs, hostname, and IPC primitives. cgroups then cap how much CPU, memory, and I/O that process is allowed to consume. That's it. No guest kernel, no hypervisor, no boot sequence — docker run is conceptually closer to fork+exec than to "start a VM."
┌────────────────────────────────────────────────┐
│ host Linux kernel │
└────────────────────────────────────────────────┘
│ │ │ │
namespaces│ namespaces│ namespaces│ namespaces│
+ cgroups │ + cgroups │ + cgroups │ + cgroups │
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ proc A │ │ proc B │ │ proc C │ │ proc D │
│ (cont. │ │ (cont. │ │ (cont. │ │ (host │
│ #1) │ │ #2) │ │ #3) │ │ proc) │
└────────┘ └────────┘ └────────┘ └────────┘
each sees its own /, PIDs, network, users
Why containers won over VMs for app delivery:
- Consistency — the same image runs on a dev laptop, CI runner, staging, and prod. "Works on my machine" stops being a defense.
- Immutability — the image is the artifact you tested. Promotion = pulling the same digest in a new environment, not redeploying source.
- Density — one Linux host can pack hundreds of containers because there's no per-VM kernel + OS overhead.
- Speed — cold start is "spawn a process," typically tens of milliseconds; a VM cold start is tens of seconds.
💡 The flip side: containers share the host kernel. A kernel-level escape is a host compromise. Multi-tenant hostile workloads need a stronger boundary (gVisor, Kata Containers, or just separate VMs).
Image vs container vs registry
Three terms, three lifecycles. Mixing them up is the most common source of Docker confusion:
| Thing | What it is | Lifecycle |
|---|---|---|
| Image | An immutable, content-addressed bundle of filesystem layers + metadata (entrypoint, env, exposed ports). Identified by a SHA digest; tagged for humans. | Built once, then read-only forever. |
| Container | A running (or stopped) instance of an image, with a thin writable top layer for runtime changes. | Created from an image, lives until removed; writable layer is discarded on delete. |
| Registry | A network service that stores and serves images by name and digest. MCR, Docker Hub, ACR, GHCR, GitLab. | Long-lived; images pushed/pulled. |
┌────────────────┐ docker pull ┌──────────────────┐
│ registry │ ───────────────────────► │ local image │
│ (MCR / ACR) │ │ cache │
└────────────────┘ └──────────────────┘
▲ │
│ docker push │ docker run
│ ▼
┌────────────────┐ ┌──────────────────┐
│ built image │ ◄── docker build ─────── │ running │
│ (local) │ │ container │
└────────────────┘ │ (writable top) │
└──────────────────┘
Mental model: image is to container what class is to instance, or what a .NET assembly is to a loaded AppDomain. One image → zero or many containers running concurrently from it.
Layered filesystem (union FS, copy-on-write)
Each instruction in a Dockerfile produces one layer — a tarball of file changes (additions, modifications, deletions) keyed by the SHA of its content. A running container stacks the image's read-only layers, adds a writable layer on top, and presents the union to the process as a single filesystem (this is OverlayFS on modern Linux). Writes go to the top layer via copy-on-write; the underlying layers stay untouched and shareable.
container's writable top layer ──► [ runtime writes go here ]
─────────────────────────────────────────────────────────────
layer N: ENTRYPOINT ["dotnet", ...] (metadata, ~0 bytes)
layer N-1: COPY --from=build /app . (your published output)
layer N-2: WORKDIR /app
...
layer 2: base runtime files ◄─ shared across every
layer 1: base OS files image built from this base
This is why COPY ordering matters: layers above an invalidated layer are also invalidated and must be rebuilt. Copy *.csproj first and run restore, then copy source — a code change invalidates only the source layer and below, not the (expensive) restore layer. It's also why slim base images matter: if your base layers are 700 MB, every container on the host shares those bytes once, but every pull is still 700 MB across the wire.
💡 Tags are mutable pointers; digests (@sha256:...) are not. Production deployments should pin digests so a :latest retag upstream can't silently change what runs.
Windows vs Linux containers (the .NET angle)
The kernel-sharing rule from the first section has a corollary: a container can only run on a host whose kernel it expects. Linux containers need a Linux kernel — on Windows hosts that means WSL2 or a Linux VM under the hood. Windows containers share a Windows kernel and only run on Windows hosts (Server / Pro / Enterprise with the feature enabled).
For .NET shops:
- Linux + chiseled is the modern default. Smaller images, the entire cloud-native ecosystem (K8s, ACA, Container Apps) is Linux-first, and modern .NET runs identically on both.
- Windows containers exist for legitimate reasons: legacy .NET Framework apps (no Linux runtime), Windows-only native dependencies (COM, Windows-specific GAC assemblies, GDI+), or PowerShell-based workloads that need a real Windows shell. They're larger (multi-GB base images) and orchestration is more constrained.
- Process isolation vs Hyper-V isolation is Windows-specific: process isolation shares the host kernel (smaller, faster); Hyper-V isolation runs a thin VM per container (stronger boundary, used in multi-tenant scenarios like Azure Container Instances on Windows).
How dotnet publish interacts with multi-stage builds
A Dockerfile can declare multiple FROM stages; only the last stage becomes the final image. Earlier stages exist only to produce artifacts that later stages copy in. This is exactly the shape of a .NET build:
- The SDK image (
mcr.microsoft.com/dotnet/sdk:9.0) is huge (~700 MB) because it carries compilers, MSBuild, NuGet, restore caches, and SDK templates. You need all of that to produce an artifact. - The artifact itself — what
dotnet publishwrites to/app/publish— is tiny: your IL DLLs, dependencies, and a tinyappsettings.json. Or, with AOT, a single native binary. - The runtime image (
aspnet:9.0-noble-chiseled) is just enough to run that artifact: the .NET runtime + ASP.NET Core libraries + glibc + ICU, sized at ~50–80 MB.
Multi-stage exists exactly to keep compilers, source code, and intermediate state out of the runtime image. The final image holds only the published output; the SDK stage is discarded after the build.
┌──────────────────────────┐
│ stage: build (SDK) │ 700 MB
│ - csproj, source │ - compilers, MSBuild, NuGet
│ - dotnet restore │
│ - dotnet publish │
│ → /app/publish │
└──────────────────────────┘
│
│ COPY --from=build /app/publish .
▼
┌──────────────────────────┐
│ stage: runtime │ ~80 MB final image
│ (chiseled aspnet) │
│ - your DLLs only │
│ - non-root user │
│ - ENTRYPOINT │
└──────────────────────────┘
The canonical multi-stage Dockerfile for ASP.NET Core:
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.sln ./
COPY src/MyApp/*.csproj src/MyApp/
RUN dotnet restore src/MyApp/MyApp.csproj
COPY . .
RUN dotnet publish src/MyApp/MyApp.csproj -c Release -o /app/publish --no-restore
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS runtime
WORKDIR /app
COPY --from=build /app/publish .
USER 1654:1654
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
Cache hits on the restore step when only source changes — fast incremental builds. See the "Layering for cache" subsection below for the reasoning.
Image variants
The choice of base image is the single biggest factor in image size, attack surface, and runtime behavior. Microsoft publishes several variants of the same .NET version, each tuned for a different trade-off between size, completeness, and host OS.
| Variant | Size | Notes |
|---|---|---|
9.0 (default) | ~200MB | Debian slim |
9.0-alpine | ~110MB | musl-libc; AOT issues |
9.0-noble-chiseled | ~50-80MB | Ubuntu, distroless-style; recommended |
9.0-noble-chiseled-extra | adds globalization, tzdata, ca-certs | most apps need this |
9.0-jammy-chiseled | older Ubuntu base | |
9.0-nanoserver-ltsc2022 | Windows |
For 2026, use noble-chiseled — Microsoft's distroless approach. Tiny; no shell; non-root by default.
Chiseled
"Chiseled" is Microsoft and Canonical's term for an Ubuntu image with everything non-essential chiseled away: no shell, no package manager (apt), no coreutils, no editors. The image contains only the files the .NET runtime actually needs to execute. The attack surface shrinks correspondingly — an attacker who lands code execution inside a chiseled container has no shell to pivot from and no package manager to install tools.
- Distroless-like Ubuntu image. No shell, no package manager.
- Smallest attack surface.
chiseled-extraadds globalization (ICU), tzdata, ca-certs — needed by many apps.
Non-root
By default a container runs as UID 0 (root) inside its user namespace. If the container escapes its boundary, that root identity often maps to a privileged user on the host. Running as a non-root user is a cheap, layered defense: even a successful exploit inside the container loses the implicit "I can do anything in this filesystem" power.
Or:
Reduces blast radius of a container compromise.
Read-only root filesystem
A complement to non-root: even if a process is compromised, prevent it from writing to the image's filesystem at all. Anything that genuinely needs to write (logs, temp files, caches) gets an explicit, narrowly-scoped writable mount. Most ASP.NET Core apps need nothing more than a writable /tmp.
App must write to mounted volumes (/tmp emptyDir, etc.).
.dockerignore
The Docker daemon snapshots the entire build context (the directory you point docker build at) and ships it to the build engine before any COPY runs. Without a .dockerignore, that snapshot includes bin/, obj/, .git, IDE state, local secrets — every byte that ever existed in the directory. The result: slow builds, bloated layers, and credentials leaking into images.
Reduces build context size and avoids secrets/local paths in images.
Layering for cache
Docker's build cache is layer-granular: as long as the input to a layer is unchanged, the cached output is reused; the moment one layer's input changes, that layer and every layer above it must be rebuilt. The optimization is therefore to push slowly-changing things to the bottom and fast-changing things to the top.
For .NET, the slowly-changing thing is *.csproj (NuGet dependencies); the fast-changing thing is source code. Restore depends only on csproj, so copying csproj first lets restore be cached across every code change.
COPY *.sln *.csproj ./ # change rarely
RUN dotnet restore # cached unless csproj changes
COPY . . # changes often
RUN dotnet publish ...
If you swap COPY order (source first, csproj after), every code change invalidates restore — slow.
AOT image
Ahead-of-Time compilation trades build-time work for runtime simplicity: instead of shipping IL DLLs + the .NET runtime that JITs them, PublishAot=true produces a single native executable with no JIT, no runtime, and no managed reflection (mostly). The runtime image can drop to runtime-deps — just glibc, ICU, and a libc loader — because there's nothing left that needs the .NET runtime.
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -p:PublishAot=true -o /app/publish
FROM mcr.microsoft.com/dotnet/runtime-deps:9.0-noble-chiseled
WORKDIR /app
COPY --from=build /app/publish .
USER 1654:1654
ENTRYPOINT ["./MyApp"]
Note runtime-deps (just glibc, ICU). Image ~30-50 MB. No JIT, no .NET runtime — just your native binary.
Caveats: - All deps must be AOT-compatible (no reflection-heavy libs). - Trim warnings break the build (good). - Build slower; runtime fast cold-start.
Health checks via Docker
A health check is a small command the runtime executes on a schedule to decide whether the container is useful, not just running. A process can be alive (PID 1 hasn't exited) but stuck — deadlocked, out of DB connections, or in an infinite GC loop. Without a health check, orchestrators have no way to tell the difference; with one, an unhealthy container can be restarted or pulled from a load balancer.
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q --spider http://localhost:8080/health || exit 1
Or rely on K8s probes (Kubernetes for .NET).
Build with dotnet publish (no Dockerfile)
Modern .NET ships with the ability to produce a container image directly from dotnet publish — the SDK builds and pushes a layered, optimized image without you writing or maintaining a Dockerfile. Useful when you want the default multi-stage shape with zero Dockerfile boilerplate, and you're happy with Microsoft's choice of base image and layering.
.NET 8+ has built-in container publish. No Dockerfile needed:
<PropertyGroup>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled</ContainerBaseImage>
<ContainerImageName>myapp</ContainerImageName>
<ContainerImageTags>1.0.0;latest</ContainerImageTags>
</PropertyGroup>
Convenient; produces a layered, optimized image.
Compose for local dev
docker compose declaratively brings up a multi-service local environment — your API, a database, maybe Redis — wired together by an internal Docker network. It's the canonical "F5 to run the whole stack" experience for developers without a full K8s cluster. Compose has no production story; it's a dev/test tool.
services:
api:
build: .
ports: ["8080:8080"]
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__Default=Server=db;...
depends_on: [db]
db:
image: postgres:16-alpine
environment: { POSTGRES_PASSWORD: dev }
Aspire (Aspire — Cloud-Native) is the modern alternative for local dev.
Image scanning
A container image is a frozen snapshot of its base layers — including any CVEs present at build time. Image scanners walk the layers, identify packages, and cross-reference public vulnerability databases. Running scans in CI and failing builds on critical findings is the only way to stop yesterday's glibc CVE from shipping to prod a year later in an image nobody rebuilt.
Scan for CVEs in CI. Fail builds on critical issues.
Distroless (Google) for .NET
mcr.microsoft.com/dotnet/runtime-deps:9.0-distroless was deprecated in favor of chiseled. Use chiseled.
ARM64 builds
ARM64 is no longer a niche: Apple Silicon dev laptops, AWS Graviton, Azure Ampere SKUs, and Raspberry Pi-class edge hardware all run it. .NET produces native binaries for both linux/amd64 and linux/arm64. docker buildx cross-builds a multi-arch image (one manifest, two platform-specific image layers under it); pulling the image on either platform gets the right one automatically.
Arm64 widely deployed (M-series Macs, Graviton). .NET supports both.
Globalization
.NET's culture-sensitive APIs (DateTime.ToString("d"), string.Compare with locale, currency formatting) depend on ICU — a large data file of culture rules that ships separately from the runtime. Chiseled images omit ICU to stay small, which is fine for pure-API workloads but breaks anything that formats user-facing dates or sorts strings by locale.
Two ways out: pull in the -extra variant (ships ICU back in), or tell .NET to run in invariant mode (every culture behaves like CultureInfo.InvariantCulture — predictable but English-flavored).
Or set:
If you don't need cultures.
Common mistakes
- Running as root.
- Unminified image (using SDK at runtime).
- Copying source before csproj (cache miss on restore).
- Hardcoded ports/secrets.
- Missing
.dockerignore.
Code: correct vs wrong
❌ Wrong: SDK image at runtime
FROM mcr.microsoft.com/dotnet/sdk:9.0
COPY . .
RUN dotnet publish -o /app
ENTRYPOINT ["dotnet", "/app/MyApp.dll"]
Image ~700 MB; includes compilers, sources.
✅ Correct: multi-stage
(See full Dockerfile above.)
❌ Wrong: root user
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY ... .
ENTRYPOINT ["dotnet", "MyApp.dll"]
# default USER root
✅ Correct: non-root
Design patterns for this topic
Pattern 1 — "Multi-stage chiseled"
- Intent: small, secure final image.
Pattern 2 — "AOT for tiny + fast cold-start"
- Intent: serverless, edge.
Pattern 3 — ".NET SDK container publish"
- Intent: no Dockerfile.
Pattern 4 — "Layering csproj first"
- Intent: restore cache.
Pattern 5 — "Read-only FS + non-root"
- Intent: least-privilege.
Pros & cons / trade-offs
| Choice | Pros | Cons |
|---|---|---|
| Chiseled | Tiny, secure | No shell for debugging |
| Alpine | Small | musl-libc AOT issues |
| Debian slim | Familiar | Larger |
| AOT | Fastest cold start | Build complexity |
When to use / when to avoid
- Always multi-stage.
- Use chiseled for production.
- Use AOT for serverless/edge.
- Avoid SDK image in production runtime.
Interview Q&A
Q1. Why multi-stage? Build with SDK; runtime with slim image. Final image excludes compilers/sources.
Q2. What's chiseled? Microsoft's distroless Ubuntu. No shell, no package manager. Tiny; secure.
Q3. AOT image size? ~30-50 MB typical. No .NET runtime — just native binary.
Q4. Why USER non-root? Reduces compromise blast radius.
Q5. Order of COPY in Dockerfile? csproj first → restore (cached); source later. Code changes don't invalidate restore.
Q6. Read-only root FS? Container can't write to /. Forces explicit volumes for state. Defense in depth.
Q7. .NET SDK container publish? dotnet publish -p:PublishProfile=DefaultContainer. No Dockerfile.
Q8. Globalization in chiseled? Not included by default. Use chiseled-extra or set invariant mode.
Q9. ARM64? docker buildx --platform linux/arm64. .NET supports.
Q10. Image scanning? docker scout, Trivy, Snyk. CI gate.
Gotchas / common mistakes
- ⚠️ SDK at runtime.
- ⚠️ Root user.
- ⚠️ No
.dockerignore— bloated build context. - ⚠️ Alpine + AOT — musl issues.
- ⚠️ Source before csproj — restore cache miss.