← All posts

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

Goodrestore 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

  1. Always use multi-stage builds. Never ship an SDK to production.
  2. Pin base images by digest, not a floating tag:
    FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:abc123...
    
    :latest makes builds non-reproducible and can silently change your runtime.
  3. Order instructions least-changing → most-changing to maximise cache hits.
  4. Combine related RUN commands and clean up in the same layer:
    RUN apt-get update \
        && apt-get install -y --no-install-recommends curl \
        && rm -rf /var/lib/apt/lists/*
    
    Cleaning in a later layer does not shrink the image — the files still exist in the earlier layer.
  5. Maintain a strict .dockerignore.
  6. One process per container. Need a sidecar? Run a second container.
  7. Keep images small — smaller means faster pulls, faster scaling, fewer CVEs.

4.2 Security

  1. Never run as root. Set an explicit USER.
  2. Never bake secrets into images. No secrets in ARG, ENV, or COPY-ed config files — docker history exposes them all.
  3. Use BuildKit secrets for build-time credentials.
  4. Inject runtime secrets via the orchestrator (Kubernetes Secrets, Azure Key Vault, AWS Secrets Manager) — not -e flags in a checked-in script.
  5. Scan every image in CI and block on HIGH/CRITICAL.
  6. Use minimal bases (chiseled, distroless, alpine) to cut attack surface.
  7. Harden the runtime:
    docker run \
      --read-only \
      --tmpfs /tmp \
      --cap-drop=ALL \
      --security-opt=no-new-privileges \
      myapp:1.0
    
  8. Never mount the Docker socket (/var/run/docker.sock) into an application container — it is equivalent to giving away root on the host.
  9. Rebuild regularly. A base image that was clean last month is not clean today. Automate weekly rebuilds.

4.3 Configuration and Environments

  1. 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.
  2. All config comes from the environment. No appsettings.Production.json baked in with real values.
  3. Validate configuration at startup and fail fast.
  4. Tag immutably: myapp:1.4.2 or myapp:git-a1b2c3d. Never redeploy a mutable tag — you lose the ability to know what is actually running.

4.4 Runtime and Operations

  1. Always define health checks (liveness + readiness, kept separate).
  2. Always set resource limits — memory, CPU, PIDs.
  3. Log to stdout/stderr as structured JSON. Never write log files inside a container; let the platform collect them.
  4. Handle SIGTERM for graceful shutdown; use the exec form of ENTRYPOINT.
  5. Keep containers stateless. State belongs in a database, cache, or object storage — anything else breaks horizontal scaling.
  6. Set restart policies: --restart=unless-stopped for standalone hosts; let the orchestrator handle it in Kubernetes.

4.5 CI/CD

  1. Build the image once per commit; push to a registry; promote that digest through environments.
  2. Cache layers between pipeline runs (--cache-from / registry cache).
  3. Run tests inside a container so the test environment matches production.
  4. Generate an SBOM and attach provenance attestations.
  5. Sign images and verify signatures at deploy time.
  6. Automate base-image updates (Dependabot/Renovate track Dockerfile FROM lines).

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, so USER $APP_UID is the idiomatic .NET way to drop privileges.