A practical guide covering Docker fundamentals, intermediate workflows, advanced techniques, and industry best practices — with .NET / ASP.NET Core examples.
Part 1 — Beginner
1.1 What Problem Does Docker Solve?
"It works on my machine" happens because the application depends on things outside the code:
- OS version
- Runtime version (.NET 6 vs .NET 8)
- Installed libraries
- Environment variables
- File paths
Docker packages the application together with its environment into a single immutable unit called an image. That image runs identically on a laptop, a build agent, and a production server.
1.2 Virtual Machine vs Container
Virtual Machine Container
------------------ ------------------
App App
Libraries Libraries
Guest OS <-- heavy (shares host kernel)
Hypervisor Docker Engine
Host OS Host OS
Hardware Hardware
- VM: boots a full OS, GBs in size, starts in minutes.
- Container: shares the host kernel, MBs in size, starts in milliseconds.
1.3 Core Concepts
| Term | Meaning |
|---|---|
| Image | Read-only template (the "class") |
| Container | A running instance of an image (the "object") |
| Dockerfile | Recipe describing how to build an image |
| Registry | Storage for images (Docker Hub, ACR, ECR) |
| Layer | Each instruction in a Dockerfile creates a cached layer |
| Volume | Persistent storage that outlives a container |
| Network | Virtual network allowing containers to talk to each other |
1.4 Essential Commands
# Images
docker pull mcr.microsoft.com/dotnet/aspnet:8.0
docker images
docker rmi <image-id>
# Build
docker build -t myapp:1.0 .
# Run
docker run -d -p 8080:8080 --name myapp myapp:1.0
# Inspect
docker ps # running containers
docker ps -a # all containers
docker logs -f myapp # follow logs
docker exec -it myapp sh # shell inside the container
# Cleanup
docker stop myapp
docker rm myapp
docker system prune -a # remove unused images/containers/networks
1.5 Your First Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o /out
ENTRYPOINT ["dotnet", "/out/MyApp.dll"]
This works, but it is bad for production: the final image contains the whole SDK, source code, and NuGet cache (~800 MB+). Part 2 fixes that.
1.6 Key Instructions
| Instruction | Purpose |
|---|---|
FROM |
Base image |
WORKDIR |
Sets the working directory (creates it if missing) |
COPY |
Copies files from build context into the image |
RUN |
Executes a command at build time (creates a layer) |
ENV |
Sets an environment variable |
EXPOSE |
Documents the port (does not publish it) |
ENTRYPOINT |
The fixed executable to run |
CMD |
Default arguments (overridable at docker run) |
ENTRYPOINT vs CMD: ENTRYPOINT is what runs, CMD is the default arguments. Use ENTRYPOINT for the binary and CMD for flags you want callers to override.
RUN vs CMD: RUN executes while building the image. CMD/ENTRYPOINT execute when the container starts.
Part 2 — Intermediate
2.1 Multi-Stage Builds
Build with the SDK, ship only the runtime.
# ---------- Build stage ----------
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy csproj first so restore is cached independently of source changes
COPY ["MyApp.Api/MyApp.Api.csproj", "MyApp.Api/"]
COPY ["MyApp.Core/MyApp.Core.csproj", "MyApp.Core/"]
RUN dotnet restore "MyApp.Api/MyApp.Api.csproj"
COPY . .
RUN dotnet publish "MyApp.Api/MyApp.Api.csproj" \
-c Release -o /app/publish /p:UseAppHost=false
# ---------- Runtime stage ----------
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
Result: ~200 MB instead of ~800 MB, with no source code or build tools in the shipped image.
2.2 Layer Caching — The Single Biggest Build-Speed Win
Docker caches each layer. A layer is invalidated when its inputs change, and every layer after it is rebuilt too.
Bad — any source change re-runs restore:
COPY . .
RUN dotnet restore
Good — restore is cached until a .csproj actually changes:
COPY *.csproj ./
RUN dotnet restore
COPY . .
Same rule for other stacks: copy package.json before npm ci, requirements.txt before pip install, go.mod before go mod download.
Order Dockerfile instructions from least-frequently-changed to most-frequently-changed.
2.3 .dockerignore
Without it, the entire folder — bin, obj, .git, node_modules — is sent to the daemon, slowing builds and busting the cache.
**/bin/
**/obj/
**/.vs/
**/node_modules/
**/.git/
**/*.user
**/appsettings.*.json
Dockerfile*
docker-compose*
README.md
2.4 Volumes and Persistence
Container filesystems are ephemeral — delete the container and the data is gone. Use volumes for anything that must survive.
# Named volume (preferred for databases)
docker volume create pgdata
docker run -d -v pgdata:/var/lib/postgresql/data postgres:16
# Bind mount (useful for local development)
docker run -d -v "$(pwd)/logs:/app/logs" myapp:1.0
| Type | Use for |
|---|---|
| Named volume | Databases, persistent app data (Docker-managed) |
| Bind mount | Local dev source/config, host log directories |
| tmpfs | Secrets and scratch data that must never touch disk |
2.5 Networking
docker network create appnet
docker run -d --name db --network appnet postgres:16
docker run -d --name myapp --network appnet myapp:1.0
Containers on the same user-defined network resolve each other by container name — the connection string host becomes db, not localhost.
-p 8080:5000 means host port 8080 → container port 5000.
2.6 Docker Compose
Compose defines a multi-container stack in one file.
services:
api:
build:
context: .
dockerfile: MyApp.Api/Dockerfile
ports:
- "8080:8080"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Default: "Host=db;Database=myapp;Username=app;Password=${DB_PASSWORD}"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
docker compose up -d --build
docker compose logs -f api
docker compose down # add -v to also delete volumes
Note depends_on with condition: service_healthy — plain depends_on only waits for the container to start, not to become ready.
2.7 Passing Configuration
Follow the same rule as any 12-factor app: configuration comes from the environment, never baked into the image.
docker run -d \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ConnectionStrings__Default="Host=db;..." \
-e JWT__Secret="..." \
myapp:1.0
In .NET, __ (double underscore) maps to the : config separator, so ConnectionStrings__Default overrides ConnectionStrings:Default.
Part 3 — Advanced
3.1 BuildKit and Cache Mounts
BuildKit is the modern builder (default in recent Docker versions). It enables parallel stages, better caching, and build secrets.
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.csproj ./
# NuGet cache persists across builds instead of being re-downloaded
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore
3.2 Build Secrets (Never Use ARG for Secrets)
ARG and ENV values are visible in docker history. Use BuildKit secrets, which are mounted only for that RUN and never persisted in a layer.
RUN --mount=type=secret,id=nugettoken \
dotnet restore --source "https://token:$(cat /run/secrets/nugettoken)@nuget.mycompany.com/v3/index.json"
docker build --secret id=nugettoken,src=./token.txt -t myapp:1.0 .
3.3 Distroless and Chiseled Images
Smaller base = smaller attack surface and fewer CVEs.
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS final
Chiseled/distroless images contain no shell, no package manager, and no utilities. This blocks a whole class of post-exploitation techniques — but it also means docker exec -it ... sh will not work, so plan on log/metrics-based debugging.
3.4 Run as a Non-Root User
Containers run as root by default. A container escape then starts with root on the host.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
RUN adduser --disabled-password --gecos "" --uid 10001 appuser \
&& chown -R appuser:appuser /app
USER appuser
# Non-root cannot bind ports below 1024 — use 8080, not 80
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
3.5 Health Checks
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Pair this with ASP.NET Core health checks:
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString)
.AddRedis(redisConnection);
app.MapHealthChecks("/health/live", new() { Predicate = _ => false });
app.MapHealthChecks("/health/ready");
- Liveness: is the process alive? (restart if not)
- Readiness: can it serve traffic? (remove from load balancer if not)
3.6 Signal Handling and Graceful Shutdown
Use the exec form so your process is PID 1 and receives SIGTERM:
ENTRYPOINT ["dotnet", "MyApp.Api.dll"] # exec form — signals delivered
# ENTRYPOINT dotnet MyApp.Api.dll # shell form — wrapped in /bin/sh -c, signals swallowed
Docker sends SIGTERM, waits (10s default), then SIGKILL. ASP.NET Core handles SIGTERM and drains in-flight requests automatically — but only if it actually receives the signal.
3.7 Resource Limits
An unbounded container can starve its neighbours.
docker run -d \
--memory=512m --memory-swap=512m \
--cpus=1.5 \
--pids-limit=200 \
myapp:1.0
Modern .NET is container-aware and sizes the GC heap and thread pool from the cgroup limits — so setting limits actually improves .NET behaviour rather than just constraining it.
3.8 Multi-Architecture Images
docker buildx create --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myregistry.io/myapp:1.0 --push .
One tag serves both x64 servers and ARM (Apple Silicon, Graviton).
3.9 Image Scanning and Signing
docker scout cves myapp:1.0
trivy image myapp:1.0
# Sign / verify provenance
cosign sign myregistry.io/myapp:1.0
cosign verify myregistry.io/myapp:1.0
Fail the pipeline on HIGH/CRITICAL findings and generate an SBOM (docker buildx build --sbom=true).
3.10 Debugging Techniques
docker history myapp:1.0 # inspect layers and their sizes
docker inspect myapp:1.0 # full image/container metadata
docker stats # live CPU/memory per container
docker diff myapp # filesystem changes since start
docker run --rm -it --entrypoint sh myapp:1.0 # poke around without starting the app
For distroless images, attach a debug sidecar sharing the namespace:
docker run --rm -it --pid=container:myapp --network=container:myapp nicolaka/netshoot
Part 4 — Industry Best Practices
4.1 Image Building
- Always use multi-stage builds. Never ship an SDK to production.
- Pin base images by digest, not a floating tag:
FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:abc123...:latestmakes builds non-reproducible and can silently change your runtime. - Order instructions least-changing → most-changing to maximise cache hits.
- Combine related
RUNcommands and clean up in the same layer:
Cleaning in a later layer does not shrink the image — the files still exist in the earlier layer.RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* - Maintain a strict
.dockerignore. - One process per container. Need a sidecar? Run a second container.
- Keep images small — smaller means faster pulls, faster scaling, fewer CVEs.
4.2 Security
- Never run as root. Set an explicit
USER. - Never bake secrets into images. No secrets in
ARG,ENV, orCOPY-ed config files —docker historyexposes them all. - Use BuildKit secrets for build-time credentials.
- Inject runtime secrets via the orchestrator (Kubernetes Secrets, Azure Key Vault, AWS Secrets Manager) — not
-eflags in a checked-in script. - Scan every image in CI and block on HIGH/CRITICAL.
- Use minimal bases (chiseled, distroless, alpine) to cut attack surface.
- Harden the runtime:
docker run \ --read-only \ --tmpfs /tmp \ --cap-drop=ALL \ --security-opt=no-new-privileges \ myapp:1.0 - Never mount the Docker socket (
/var/run/docker.sock) into an application container — it is equivalent to giving away root on the host. - Rebuild regularly. A base image that was clean last month is not clean today. Automate weekly rebuilds.
4.3 Configuration and Environments
- Build once, deploy everywhere. The same image artifact is promoted Dev → QA → Production; only environment variables differ. Rebuilding per environment defeats the entire point of containers.
- All config comes from the environment. No
appsettings.Production.jsonbaked in with real values. - Validate configuration at startup and fail fast.
- Tag immutably:
myapp:1.4.2ormyapp:git-a1b2c3d. Never redeploy a mutable tag — you lose the ability to know what is actually running.
4.4 Runtime and Operations
- Always define health checks (liveness + readiness, kept separate).
- Always set resource limits — memory, CPU, PIDs.
- Log to stdout/stderr as structured JSON. Never write log files inside a container; let the platform collect them.
- Handle
SIGTERMfor graceful shutdown; use the exec form ofENTRYPOINT. - Keep containers stateless. State belongs in a database, cache, or object storage — anything else breaks horizontal scaling.
- Set restart policies:
--restart=unless-stoppedfor standalone hosts; let the orchestrator handle it in Kubernetes.
4.5 CI/CD
- Build the image once per commit; push to a registry; promote that digest through environments.
- Cache layers between pipeline runs (
--cache-from/ registry cache). - Run tests inside a container so the test environment matches production.
- Generate an SBOM and attach provenance attestations.
- Sign images and verify signatures at deploy time.
- Automate base-image updates (Dependabot/Renovate track Dockerfile
FROMlines).
4.6 Common Mistakes to Avoid
| Mistake | Why it hurts |
|---|---|
Using FROM ...:latest |
Non-reproducible builds; runtime can change under you |
COPY . . before restore/npm ci |
Destroys layer cache; every build is a cold build |
Secrets in ENV or ARG |
Permanently visible in image history |
| Running as root | Container escape becomes host root |
No .dockerignore |
Slow builds, bloated context, leaked .git/.env |
Shell-form ENTRYPOINT |
SIGTERM never reaches the app; hard kills, dropped requests |
| Writing data to the container FS | Silent data loss on restart |
| No resource limits | One container starves the whole host |
| Rebuilding per environment | Dev/QA/Prod drift — the exact problem containers exist to solve |
| Installing debug tools in prod images | Extra CVEs and a richer toolkit for an attacker |
Quick Reference: Production-Ready .NET Dockerfile
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApp.Api/MyApp.Api.csproj", "MyApp.Api/"]
COPY ["MyApp.Core/MyApp.Core.csproj", "MyApp.Core/"]
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore "MyApp.Api/MyApp.Api.csproj"
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish "MyApp.Api/MyApp.Api.csproj" \
-c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS final
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080 \
DOTNET_gcServer=1
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
The chiseled images define
$APP_UID(a non-root user) for you, soUSER $APP_UIDis the idiomatic .NET way to drop privileges.
Related
- Environment-Configuration-Consistency.md — keeping Dev/QA/Production configuration aligned