Docker and containers
How to package a .NET app into a container and run it next to its dependencies and in a cluster: how a container differs from a virtual machine, how to build a small and secure image, how to bring up a local stack with docker compose and what Kubernetes expects from the app. Interviewers check this with questions about layers, ports, probes and limits.
Before this topic: Hosting and the pipeline.
What is a container and how does it differ from a virtual machine?
Section titled “What is a container and how does it differ from a virtual machine?”What it is
- Image — an immutable template: app files, runtime, system libraries and the start command.
- Container — a running instance of an image: a process with its own file system, network and limits.
- Dockerfile — a text recipe for building an image from
FROM,COPY,RUN,ENTRYPOINTinstructions. - Registry — image storage: Docker Hub,
mcr.microsoft.com, Azure Container Registry.
Why
One artifact behaves the same on a laptop, in CI and on a server: the right runtime version is already inside the image.
How to use
docker build— build an image from a Dockerfile.docker run -p 5000:8080— run it: host port 5000 leads to container port 8080.-epasses an environment variable,-vattaches a volume so data survives the container being recreated.
Common mistake
Storing data inside the container: uploaded files, SQLite, logs. After docker rm or an image update they are gone.
Takeaway: the image is what to run, the container is the running process, and a volume is where to keep what must outlive it.
How it works
- Layers. Each
RUN,COPYorADDinstruction produces a layer — a set of file system changes. Layers are read-only, addressed by content hash and shared between images. - Startup. Docker stacks the layers (overlayfs) and adds a writable container layer on top.
- Isolation. Namespaces give the process its own PIDs, network, mount points and hostname: the app inside is PID 1. Cgroups cap CPU, memory and the number of processes.
- Ports. A container has its own network stack.
EXPOSEin a Dockerfile is only documentation;-p host:containeropens access from outside.
When to choose what
| Aspect | Container | Virtual machine |
|---|---|---|
| OS kernel | shared with the host | its own |
| Startup | fractions of a second | tens of seconds |
| Size | tens to hundreds of MB | gigabytes |
| Isolation | process-level | hardware-level, stronger |
Pitfalls
- Volume or bind mount. A named volume (
-v data:/data) is managed by Docker. A bind mount (-v ./conf:/app/conf) maps a host folder — handy in development but ties you to a specific machine. - Linux containers on Windows. Docker Desktop runs them in a lightweight WSL 2 VM: a container still needs a Linux kernel.
- A tag is not a version.
latestand even1.0can be overwritten. The immutable reference is the digest@sha256:....
Rule of thumb
A container is disposable: settings go in environment variables, data in volumes or external services, logs to stdout.
Takeaway: a container is an isolated process on a shared kernel built from cached layers, not a small VM.
Under the hood
- The launch chain: Docker CLI → dockerd → containerd → runc. The image and runtime formats are defined by the OCI standard, so Podman and Kubernetes run the same image.
- Namespaces: pid, net, mnt, uts, ipc, user, cgroup. Cgroups v2 set
cpu.maxandmemory.max: exceeding memory gets the process killed by the kernel, exceeding CPU gets it throttled. - PID 1 receives signals.
ENTRYPOINT ["dotnet", "Shop.Api.dll"](exec form) gets SIGTERM directly. The shell form runs the app under/bin/sh -c, the signal never reaches it, anddocker stopkills the process after 10 seconds. - Windows containers (Server Core, Nano Server) exist, including ones with IIS and .NET Framework, but they run only on a Windows host and weigh gigabytes.
Cost
- A shared kernel is a shared attack surface. A container with
--privilegedor a mounteddocker.sockis effectively root on the host. - Writes to the container layer go through copy-on-write and are slower than writes to a volume.
Trade-offs
- Density and delivery speed versus isolation strength: containers are enough for your own code; foreign code needs VMs or gVisor and Kata Containers.
- An immutable image makes rollback easy — run the previous tag — but configuration and state have to live outside.
How to check in production
docker inspect— limits, mounts, variables;docker stats— CPU and memory against the limits.docker history— which instructions produced the heaviest layers.- Exit code 137 (128 + 9) — the process was killed with SIGKILL: by OOM or after the stop timeout.
Takeaway: a container is a host process with restricted visibility and resources, and its strengths and weaknesses follow from the shared kernel.
# Build an image from the Dockerfile in the current folderdocker build -t shop-api:1.0 .
# Host port 5000 → port 8080 in the container, a variable, a volume and limitsdocker run -d --name shop-api -p 5000:8080 \ -e ASPNETCORE_ENVIRONMENT=Staging \ -v shop-uploads:/app/uploads \ --memory 512m --cpus 1.5 \ shop-api:1.0
docker ps # running containersdocker logs -f shop-api # the app's stdoutdocker stop shop-api # SIGTERM, SIGKILL after 10 secondsdocker rm shop-api # container removed, the shop-uploads volume remains- How does an image differ from a container? — An image is an immutable template, a container is a running instance with its own writable layer; one image can run any number of containers.
- What happens to files written inside a container? — They live until the container is removed; only volumes and bind mounts survive it.
- Why
EXPOSEif there is-p? —EXPOSEdocuments the port,-ppublishes it (or-Pfor all ports fromEXPOSE). - Why doesn’t the app shut down cleanly on
docker stop? — Shell-formENTRYPOINT: SIGTERM goes tosh, not to the app. - Can you run a Linux container on Windows? — Yes, via Docker Desktop with WSL 2: the Linux kernel runs in a lightweight VM.
How do you build a good image for a .NET app?
Section titled “How do you build a good image for a .NET app?”What it is
A multi-stage Dockerfile has several FROM instructions in one file. Each stage is a separate image, and only the last stage plus whatever was copied into it with COPY --from ends up in the result.
Why
The SDK is needed only to build; in production it is hundreds of extra megabytes and a compiler handy for an attacker. A simple Web API on aspnet:10.0 is about 90 MB compressed.
How to use
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build— the build stage.- Copy the
.csprojand rundotnet restore— packages land in a separate layer. - Copy the code and run
dotnet publish -o /app. FROM mcr.microsoft.com/dotnet/aspnet:10.0, thenCOPY --from=build /app .andENTRYPOINT.- List
bin/andobj/in.dockerignoreso they stay out of the build context.
Common mistake
Mapping port 80 out of habit. Since .NET 8 the app in the official image listens on 8080 (ASPNETCORE_HTTP_PORTS=8080), and -p 5000:80 does not work.
Takeaway: build in sdk, run in aspnet, and copy only the publish output between stages.
How it works
- Layer cache. Docker reuses a layer if the instruction and its input files have not changed, and rebuilds every layer after the first changed one. That is why the
.csprojis copied before the code: editing a.csfile does not rerun restore. - Microsoft images. The
10.0tags point to Ubuntu 24.04 (Noble); there are no Debian images for .NET 10.
| Image | What is inside | Used for |
|---|---|---|
sdk:10.0 |
SDK, MSBuild, compilers | build and tests |
aspnet:10.0 |
.NET and ASP.NET Core runtime | web apps |
runtime:10.0 |
.NET runtime | console, worker |
runtime-deps:10.0 |
system libraries only | self-contained, Native AOT |
When to choose what
- Chiseled (
aspnet:10.0-noble-chiseled) — a distroless Ubuntu image without a shell or package manager, non-root by default. There is no ICU or tzdata and invariant globalization is on; if you need cultures and time zones, use-noble-chiseled-extra. - Composite (
-noble-chiseled-composite) — the framework is compiled into one ReadyToRun file: the image is smaller, but the framework version is tightly coupled. - No Dockerfile —
dotnet publish /t:PublishContainer: the SDK picks the base image from the project type, theappuser and the port;ContainerFamilyselects the variant.
Pitfalls
- Regular images include the
appuser, but the process runs as root until you addUSER $APP_UID. And non-root cannot write to/appor to volumes created by root. - A
HEALTHCHECKusingcurldoes not work in a chiseled image: there is neither curl nor a shell.
Rule of thumb
Order layers from rarely changing to frequently changing; keep the final image minimal, non-root and explicitly versioned.
Takeaway: multi-stage, restore as its own layer, aspnet or chiseled at runtime and non-root are the basic hygiene of a .NET image.
Under the hood
- BuildKit, Docker’s default builder, runs independent stages in parallel.
RUN --mount=type=cache,target=/root/.nuget/packageskeeps the NuGet cache between builds even when the restore layer is rebuilt. FROM --platform=$BUILDPLATFORMwithdotnet publish -a $TARGETARCHbuilds an arm64 image on an x64 agent without emulation: the SDK runs on the native architecture.PublishContainerneeds no Docker daemon: the SDK downloads the base image layers, adds a layer with the app and pushes the image to a registry (ContainerRegistry) or to an archive (ContainerArchiveOutputPath).
Cost
Compressed image of a simple Web API, per the dotnet-docker measurement from November 2025:
| Variant | Size |
|---|---|
aspnet:10.0 |
~92 MB |
aspnet:10.0-noble-chiseled |
~53 MB |
Native AOT on runtime-deps:10.0-noble-chiseled |
~12 MB |
Native AOT gives fast startup and less memory but restricts reflection and dynamic code loading, and it builds in sdk:10.0-noble-aot. Details are in .NET platform.
Trade-offs
- The floating
10.0tag brings security patches on rebuild, a digest brings reproducibility. Teams usually pin the digest and let Dependabot or Renovate update it. - Chiseled without a shell gives you no way in: you debug with a separate container alongside (
kubectl debug) or through dotnet-monitor.
How to check in production
docker history— the weight of each layer.- A vulnerability scanner (Trivy, Docker Scout) on the image. In .NET 10, chiseled images include a Chisel manifest that lets scanners see the packages.
docker image inspect -f "{{ .Config.User }}" image— which user it will run as.
Takeaway: a good image is minimal, non-root, reproducible and quick to rebuild, and chiseled, composite and AOT are a choice of size versus compatibility.
# Build stage: the SDK is needed only hereFROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS buildARG TARGETARCHWORKDIR /src
# Project file first: the restore layer comes from cache until packages changeCOPY Shop.Api/Shop.Api.csproj Shop.Api/RUN dotnet restore Shop.Api/Shop.Api.csproj -a $TARGETARCH
# Then the code and publish (Release is the default since .NET 8)COPY . .RUN dotnet publish Shop.Api/Shop.Api.csproj -a $TARGETARCH --no-restore -o /app
# Runtime stage: no shell or package manager, non-root by defaultFROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseledWORKDIR /appCOPY --from=build /app .EXPOSE 8080ENTRYPOINT ["dotnet", "Shop.Api.dll"]# The SDK picks aspnet:10.0-noble-chiseled, the app user and port 8080 itselfdotnet publish Shop.Api --os linux --arch x64 /t:PublishContainer \ -p ContainerRepository=shop-api \ -p ContainerFamily=noble-chiseled \ -p ContainerImageTag=1.4.2
# The image is already in local Dockerdocker run -p 5000:8080 shop-api:1.4.2- Why copy the
.csprojseparately from the code? — So the restore layer comes from cache until dependencies change. - Why doesn’t a .NET 8+ app in a container answer on port 80? — The default port is 8080 (
ASPNETCORE_HTTP_PORTS): an unprivileged port is needed to run without root. - How does chiseled differ from Alpine? — Both lack ICU by default; Alpine is musl with a shell and apk, chiseled is glibc Ubuntu without a shell or package manager.
- When won’t
PublishContainerdo? — When the image needsRUNsteps: system packages, certificates. Then use a Dockerfile or your own base image. - How do you debug a container without a shell? — A debug container alongside (
kubectl debug), dotnet-monitor as a sidecar, dumps to a volume.
How do you run an app with its dependencies using docker compose?
Section titled “How do you run an app with its dependencies using docker compose?”What it is
A compose.yaml file lists services — containers with an image, ports, variables and volumes. docker compose up -d creates and starts them all; docker compose down stops and removes the containers but keeps the volumes.
Why
A developer gets the API, PostgreSQL and Redis at the right versions with one command, without installing anything on the machine.
How to use
- Describe the
api,dbandcacheservices. - In the API connection string use the service name:
Host=db;Port=5432. - Put the database data in a named volume.
- Publish (
ports) only what you access from the host.
Common mistake
Writing localhost in the connection string from a container to the database. Inside a container localhost is the container itself; the database is reachable as db.
Takeaway: compose describes the whole stack in one file, and services talk by name inside a shared network.
How it works
- Compose reads
compose.yaml, automatically addscompose.override.yamland substitutes${VAR}from.envand the environment. - It creates the project network: the service name becomes a DNS name. Ports between services do not need publishing.
- It creates volumes and starts containers in
depends_onorder, waiting for the specified conditions.
When to choose what
| Task | Tool |
|---|---|
| Wait for the DB to be ready | healthcheck and condition: service_healthy |
| Regular settings | environment, env_file |
| Passwords and keys | secrets — a file at /run/secrets/name |
| Optional services | profiles and --profile tools |
| Environment differences | override files, -f compose.yaml -f compose.prod.yaml |
Pitfalls
- The short
depends_onwaits for the container to start, not for Postgres to be ready. - A
healthcheckruns inside the container. The chiseled API image has nocurland no shell, so you cannot write such a check. - A variable from
environmentis visible indocker inspect, so it is not fit for secrets. docker compose down -vdeletes the volumes along with the database data.
Rule of thumb
The base file is shared by all environments; ports, source bind mounts and debugging services go into an override or profiles.
Takeaway: compose gives you a network with names, healthcheck-based startup order and separation of settings and secrets — enough for a local stack.
Under the hood
- Compose v2 is the
docker composeplugin, a client of the Docker Engine API. The project name (the folder name by default) prefixes networks, volumes and containers. Theversionfield is obsolete: Compose always validates against the latest schema. - In Compose,
secretsare a file mounted at/run/secretswithout encryption. .NET reads such files viaAddKeyPerFile— see Configuration and logging. docker compose watch, driven by thedevelop.watchsection, syncs files or rebuilds the image when code changes.
Cost
One host: no spreading across nodes, no autoscaling, no moving a failed service to another server and no zero-downtime rollout. restart: unless-stopped restarts the process but does not survive a machine failure.
Trade-offs
- Compose or Aspire. Aspire (no longer prefixed with “.NET” since version 13) describes the stack in code in an AppHost:
AddPostgres,WithReference,WaitFor. It runs .NET projects as plain processes and dependencies as containers, provides an OpenTelemetry dashboard, andAddDockerComposeEnvironmentgenerates a compose file for deployment. Compose is simpler and stack-agnostic; Aspire is more convenient when a solution has many .NET services. - Compose in production is reasonable for a single server and internal tools. For multiple nodes and an SLA — Kubernetes or a managed platform.
How to check in production
docker compose config— the final configuration after merging files and substituting variables.docker compose psshows healthcheck status,docker compose logs -f apishows a service’s logs.
Takeaway: compose is the best tool for a stack on one machine; beyond that comes Kubernetes, and for a .NET developer’s local stack there is also Aspire.
services: api: build: . ports: - "5000:8080" environment: ASPNETCORE_ENVIRONMENT: Development # The database host is the service name on the compose network ConnectionStrings__Shop: "Host=db;Database=shop;Username=shop" secrets: - db_password # file /run/secrets/db_password depends_on: db: condition: service_healthy # wait for readiness, not just startup
db: image: postgres:18 environment: POSTGRES_USER: shop POSTGRES_DB: shop POSTGRES_PASSWORD_FILE: /run/secrets/db_password secrets: - db_password volumes: - pgdata:/var/lib/postgresql # since postgres:18 — no /data at the end healthcheck: test: ["CMD-SHELL", "pg_isready -U shop -d shop"] interval: 5s retries: 10
adminer: image: adminer profiles: ["tools"] # only with --profile tools ports: - "8081:8080"
volumes: pgdata:
secrets: db_password: file: ./secrets/db_password.txt- Why doesn’t
depends_onguarantee the database is ready? — The short form only waits for the container to start; you needcondition: service_healthy, plus connection retries in the app. - Why can’t the API container connect to
localhost:5432? —localhostis the container itself; the database is reachable by service name. - How do you separate development and production settings? — Override files, profiles,
.envfor substitution; verify the result withdocker compose config. - Why are
secretsbetter thanenvironmentfor a password? — A file in/run/secretsis not visible indocker inspectand is not inherited by child processes. - How do you start a database for integration tests? — With Testcontainers from the test code — see Testing.
What does a .NET developer need to know about running on Kubernetes?
Section titled “What does a .NET developer need to know about running on Kubernetes?”What it is
- Pod — the smallest unit of deployment: one or more containers sharing an IP, usually one instance of the app.
- Deployment — a description like “I need 3 replicas of
shop-api:1.4.2”: it creates pods and replaces them on update. - Service — a stable name and IP for a set of pods that balances requests across them.
- Ingress — rules for incoming HTTP from outside: host, path, TLS. Its successor is the Gateway API.
Why
Kubernetes keeps the desired number of replicas by itself: if a process or a server fails, pods are created again.
How to use
- Add
/healthz/liveand/healthz/readyendpoints to the app. - Reference them in the manifest as
livenessProbeandreadinessProbe. - Set
resources: how much CPU and memory the pod needs and how much it may use. - Pass settings from ConfigMaps and Secrets.
Common mistake
Keeping anything important in the pod: files on disk, sessions in memory. On the next deploy the pod is recreated and the data is gone.
Takeaway: a Deployment keeps the right number of pods, a Service gives them an address, and the app helps with probes and by holding no state.
How it works
| Probe | On failure | What to check |
|---|---|---|
| startup | restart; other probes wait for it | startup is done: migrations, warm-up |
| liveness | container restart | the process is alive, no external dependencies |
| readiness | pod removed from the Service | ready for traffic: DB and cache reachable |
- In ASP.NET Core,
AddHealthChecks()registers checks andMapHealthCheckswith a tag-basedPredicateexposes separate endpoints. Details are in Observability and diagnostics. - By default a probe runs every 10 seconds with a 1-second timeout and fails after 3 consecutive failures.
- Requests are a reservation: the scheduler picks a node by them. Limits are a ceiling: exceeding memory means OOMKilled, exceeding CPU means throttling.
Pitfalls
- A liveness probe that checks the DB: the database goes down, every pod restarts, and recovery takes longer.
- A slow start without a startup probe: liveness kills the pod before it is up, and it ends up in
CrashLoopBackOff. - ConfigMaps and Secrets exposed as environment variables do not update without a pod restart. Mounted files do update, but not with
subPath. - By default a Secret is stored unencrypted in etcd — base64 is only an encoding.
Rule of thumb
Liveness is cheap and dependency-free, readiness is about accepting traffic, startup is for slow starts.
Takeaway: probes, resources and configuration are three contracts between the app and Kubernetes, each with its own rules.
Under the hood
- .NET reads cgroups v1 and v2.
Environment.ProcessorCountis the minimum of the core count, affinity and the CPU limit rounded up; requests are ignored.DOTNET_PROCESSOR_COUNToverrides the value. - The GC treats the memory limit as physical memory, and
GCHeapHardLimitdefaults to 75% of it. Details are in Garbage collection and resources. - When a pod is deleted, the controller removes it from the EndpointSlice while the kubelet runs preStop and sends SIGTERM in parallel. Until load balancers catch up, requests keep arriving.
preStop: sleep(stable since Kubernetes 1.34) needs no shell, which matters for chiseled images. Host shutdown is covered in Hosting and the pipeline.
Cost
- Without a CPU limit, a pod with a 0.5 CPU request on a 64-core node sees 64 cores: Server GC and the thread pool size themselves for resources that are not theirs.
- A CPU limit slices the quota into periods: GC and JIT bursts at startup hit throttling, and tail latencies grow.
Trade-offs
- A common choice is a CPU request without a limit plus
DOTNET_PROCESSOR_COUNT, or a limit in whole cores. For memory a limit is mandatory, usually equal to the request. - Stateless: sessions and cache in Redis, files in object storage, Data Protection keys in shared storage. Otherwise a cookie issued by one pod cannot be decrypted by another. Sticky sessions only mask the problem.
How to check in production
kubectl describe pod— the restart reason (OOMKilled) and probe failures;kubectl top pod— consumption.- The
container_cpu_cfs_throttled_periods_totalmetric shows throttling; dotnet-counters showsdotnet.process.cpu.countand heap size against the limit.
Takeaway: on Kubernetes a .NET service reports readiness honestly, fits within limits the runtime can see, shuts down cleanly and keeps no state in the pod.
using Microsoft.AspNetCore.Diagnostics.HealthChecks;using Microsoft.Extensions.Diagnostics.HealthChecks;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks() // liveness: the process is alive, external dependencies are not checked .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]) // readiness: can we accept traffic .AddCheck<DatabaseCheck>("database", tags: ["ready"]);
var app = builder.Build();
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = c => c.Tags.Contains("live") });app.MapHealthChecks("/healthz/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
app.Run();
public class DatabaseCheck : IHealthCheck{ public Task<HealthCheckResult> CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { // Here: a cheap DB query with a short timeout return Task.FromResult(HealthCheckResult.Healthy()); }}apiVersion: apps/v1kind: Deploymentmetadata: name: shop-apispec: replicas: 3 selector: matchLabels: { app: shop-api } template: metadata: labels: { app: shop-api } spec: terminationGracePeriodSeconds: 45 # more than preStop + ShutdownTimeout containers: - name: api image: registry.example.com/shop-api:1.4.2 ports: - containerPort: 8080 envFrom: - configMapRef: { name: shop-api-config } - secretRef: { name: shop-api-secrets } resources: requests: { cpu: "500m", memory: "512Mi" } limits: { cpu: "2", memory: "512Mi" } # ProcessorCount = 2, GC heap ≈ 384 MB startupProbe: httpGet: { path: /healthz/live, port: 8080 } failureThreshold: 30 # up to 30 × 10 seconds to start livenessProbe: httpGet: { path: /healthz/live, port: 8080 } readinessProbe: httpGet: { path: /healthz/ready, port: 8080 } lifecycle: preStop: sleep: { seconds: 10 } # let load balancers drop the pod---apiVersion: v1kind: Servicemetadata: name: shop-apispec: selector: { app: shop-api } ports: - port: 80 targetPort: 8080- How does readiness differ from liveness? — Readiness takes the pod out of load balancing, liveness restarts the container.
- Why do you see 502s during a deploy even though the app handles SIGTERM? — EndpointSlice removal runs in parallel with SIGTERM; a preStop pause helps.
- How many cores will .NET see in a pod with
limits.cpu: 1500m? — Two: the limit is rounded up. - What happens when the memory limit is exceeded? — The kernel kills the process (OOMKilled, exit code 137); the GC tries to keep the heap within 75% of the limit.
- How do you store Data Protection keys in a cluster? — In shared storage (Redis, a DB, blob) via
PersistKeysTo..., otherwise other pods cannot read the cookies.