Docker for .NET Developers — Complete Beginner to Production Guide

Docker .NET 10 DevOps

Docker for .NET Developers — Complete Beginner to Production Guide

Stop hearing "works on my machine." Learn how to containerize ASP.NET Core 10 applications properly — Dockerfiles, multi-stage builds, docker-compose, layer caching, and production-grade security practices.

AS

Adnan Shaukat

June 18, 2026  ·  14 min read  ·  Senior .NET Backend Developer

"It works on my machine" is the most expensive sentence in software development. Different .NET SDK versions, missing environment variables, OS-level dependencies — these differences between a developer's laptop and the production server cause countless deployment failures.

Docker solves this completely. Containerizing .NET applications is not just about wrapping your code in Docker — it is about building images that are small, secure, and production-ready from day one. In this guide I will walk you through everything — from your first Dockerfile to a production-grade docker-compose setup with health checks, security hardening, and CI/CD integration.

01 — Why Docker Why Containerize .NET Applications?

Docker images are written in the Dockerfile format to be deployed and run in a layered container — these images can run anywhere Docker is supported: Linux and Windows containers, on-premises data centers, every major cloud provider (AWS ECS, Azure Container Instances, Google Cloud Run), and Kubernetes.

Every developer, CI agent, and production server running your Docker image executes identical bytecode in an identical runtime environment — no more "works on my machine" frustrations. Configuration drift, where servers gradually diverge due to manual patches or version mismatches, becomes a non-issue when your entire runtime is packaged as code.

The promise of containerization

Write once, deploy everywhere. A .NET 10 Docker image runs identically on your laptop, your CI pipeline, staging, and production — whether on-premises, cloud-hosted, or hybrid.

02 — First Steps Your First Dockerfile — Single Stage

Let us start with the simplest possible Dockerfile for an ASP.NET Core 10 Web API. This is the version most tutorials show — useful for understanding the basics, but NOT what you should ship to production.

Dockerfile
# Simple version — works but NOT production-optimised FROM mcr.microsoft.com/dotnet/sdk:10.0 WORKDIR /app COPY . . RUN dotnet restore RUN dotnet publish -c Release -o /app/publish WORKDIR /app/publish EXPOSE 8080 ENTRYPOINT ["dotnet", "MyApi.dll"]

Why this is wrong for production

This Dockerfile uses the full SDK image (which includes compilers and build tools) as your RUNTIME image. This means your final container ships with hundreds of megabytes of build tools that are never needed at runtime — increasing image size, attack surface, and deployment time.

03 — Production Pattern Multi-Stage Builds — The Production Way

Multi-stage builds let you reduce the size of your final image by creating a cleaner separation between building your image and the final output. You use the heavy SDK image to build your app, then copy only the compiled output into a lightweight runtime image.

Dockerfile
# ===== STAGE 1: Build ===== FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src # Copy only csproj first — maximises Docker layer cache hits COPY ["MyApi/MyApi.csproj", "MyApi/"] RUN dotnet restore "MyApi/MyApi.csproj" # Now copy the rest of the source code COPY . . WORKDIR /src/MyApi RUN dotnet build "MyApi.csproj" -c Release -o /app/build # ===== STAGE 2: Publish ===== FROM build AS publish RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish \ /p:UseAppHost=false # ===== STAGE 3: Final runtime image (small!) ===== FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app # Run as non-root user — security best practice RUN groupadd -g 10001 dotnet && \ useradd -u 10000 -g dotnet dotnet && \ chown -R dotnet:dotnet /app USER dotnet:dotnet COPY --from=publish --chown=dotnet:dotnet /app/publish . EXPOSE 8080 ENTRYPOINT ["dotnet", "MyApi.dll"]

Why this matters

The final image is built FROM the runtime-only base image — not the SDK. Your shipped container has no compilers, no build tools, no source code — just the compiled DLLs and the .NET runtime. This dramatically reduces image size and attack surface.

04 — Essential File .dockerignore — What to Exclude

Before evaluating the COPY command, your entire project's root is sent to the Docker daemon as the build context. Excluding the bin and obj folders is important — they contain compiled binaries from your host machine that are not needed, since the build happens inside the container.

.dockerignore
**/bin/ **/obj/ **/.vs/ **/.vscode/ **/.git/ **/.gitignore **/*.user **/node_modules/ **/.env **/appsettings.Development.json **/Dockerfile* **/docker-compose* **/*.md **/tests/

Why .dockerignore matters

A well-configured .dockerignore boosts build speed, avoids secret leaks, and reduces the final image size. Never let your .env files or local secrets end up inside a Docker image.

05 — Build Speed Layer Caching — Dramatically Faster Builds

Docker caches each layer — each instruction in your Dockerfile. When you change your C# code, Docker rebuilds only the layers after the change, reusing earlier cached layers. By copying only the .csproj file and running dotnet restore early, you maximise cache hits — if only your code changes and not your dependencies, the restore layer is skipped entirely.

Dockerfile
# ❌ Bad ordering — invalidates cache on EVERY code change COPY . . RUN dotnet restore RUN dotnet build # ✅ Good ordering — restore layer cached unless csproj changes COPY ["MyApi.csproj", "."] RUN dotnet restore # cached if csproj unchanged COPY . . # only this layer rebuilds on code change RUN dotnet build

Critical apt-get caching gotcha

Using apt-get update alone in a separate RUN line causes caching issues — subsequent apt-get install instructions can use stale cached data. Always combine update and install in the SAME RUN instruction: RUN apt-get update && apt-get install -y package

06 — Local Development Docker Compose — Full Local Environment

Real applications need a database, caching, and message queues. Docker Compose lets you define your entire local stack in one file — your API, SQL Server, Redis, and RabbitMQ all running together.

docker-compose.yml
services: api: build: context: . dockerfile: Dockerfile ports: - "5000:8080" environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__Default=Server=db;Database=MyApp;User=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=true - Redis__ConnectionString=redis:6379 depends_on: db: condition: service_healthy redis: condition: service_started networks: - app-network restart: unless-stopped db: image: mcr.microsoft.com/mssql/server:2022-latest environment: - ACCEPT_EULA=Y - MSSQL_SA_PASSWORD=YourStrong!Passw0rd ports: - "1433:1433" volumes: - sqlserver-data:/var/opt/mssql healthcheck: test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "YourStrong!Passw0rd" -C -Q "SELECT 1" || exit 1 interval: 10s timeout: 5s retries: 5 networks: - app-network redis: image: redis:7-alpine ports: - "6379:6379" networks: - app-network networks: app-network: driver: bridge volumes: sqlserver-data:
# Start the entire stack with one command
docker compose up -d

# View logs
docker compose logs -f api

# Stop and remove everything
docker compose down

07 — Production Security Security Best Practices

1. Never hardcode secrets in the Dockerfile

Bash + C#
# ❌ Bad — secrets baked into image, visible in image history # ENV DATABASE_PASSWORD="hardcoded" # ✅ Good — pass secrets at runtime, never at build time docker run -d -e DATABASE_PASSWORD="$DB_PASSWORD" myapi:latest // ✅ Production — use Azure Key Vault builder.Configuration.AddAzureKeyVault( new Uri($"https://{keyVaultName}.vault.azure.net/"), new DefaultAzureCredential());

2. Always run as a non-root user

Running containers as a non-root user substantially decreases the risk that container-to-host privilege escalation could occur. UIDs below 10,000 are a security risk — if privilege escalation occurs, your container UID may overlap with a privileged system user's UID.

Dockerfile
RUN groupadd -g 10001 dotnet && \ useradd -u 10000 -g dotnet dotnet && \ chown -R dotnet:dotnet /app USER dotnet:dotnet # Note: ASP.NET Core cannot bind to port 80 without root # Always use port 8080+ when running as non-root

3. Use the smallest base image that meets your needs

Choose the smallest base image — chiseled images are smaller than alpine, which are smaller than the standard images. A smaller base image offers fast downloads and shrinks the number of vulnerabilities introduced through dependencies.

# Largest to smallest — choose based on your needs
mcr.microsoft.com/dotnet/aspnet:10.0           # ~220MB standard
mcr.microsoft.com/dotnet/aspnet:10.0-alpine    # ~110MB alpine
mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled # ~90MB chiseled, no shell

4. Pin specific versions, never use "latest"

# ❌ Bad — unpredictable, breaks reproducible builds
FROM mcr.microsoft.com/dotnet/aspnet:latest

# ✅ Good — specific, reproducible version
FROM mcr.microsoft.com/dotnet/aspnet:10.0.2-noble-chiseled

5. Scan images for vulnerabilities

# Scan with Trivy before pushing to production
trivy image myapi:latest

# Or use Docker Scout
docker scout cves myapi:latest

08 — Reliability Health Checks and Resource Limits

Production containers need health checks so orchestrators (Kubernetes, Docker Swarm) know when to restart a failing container, and resource limits so one runaway container cannot starve the whole node.

docker-compose.yml
services: api: image: myapi:latest user: "10000:10001" read_only: true security_opt: - no-new-privileges:true healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 3s retries: 3 start_period: 40s deploy: resources: limits: cpus: '1.0' memory: 256M reservations: cpus: '0.25' memory: 128M logging: driver: "json-file" options: max-size: "10m" max-file: "3"

Real-world memory guidance

While .NET can technically run with ~125Mi memory, in practice this leads to poor startup performance. Pushing memory to 175Mi+ ensures decent startup times and runtime stability — these numbers reflect Microsoft's own AKS baseline examples.

Add a health check endpoint in your ASP.NET Core 10 app:

C# — Program.cs
builder.Services.AddHealthChecks() .AddDbContextCheck<AppDbContext>("database") .AddRedis(builder.Configuration.GetConnectionString("Redis")!); app.MapHealthChecks("/health");

09 — Automation CI/CD Integration

Use CI to build the app, and let Docker just package it. Keeping your Dockerfile lean and letting your CI pipeline handle the build-and-publish workflow gives you inspectable artifacts and faster, more reliable deployments.

GitHub Actions
name: Build and Push Docker Image on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build and push uses: docker/build-push-action@v6 with: file: ./Dockerfile context: . push: true tags: myregistry.azurecr.io/myapi:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max

Quick Reference Production Docker Checklist for .NET

Always use multi-stage builds — SDK for build, runtime-only for final image

Copy .csproj before source code to maximise layer cache hits

Choose the smallest base image that meets your needs — chiseled > alpine > standard

Pin specific version tags — never use "latest" in production

Run containers as non-root users — UID 10000+

Never include secrets in the image — use environment variables or Key Vault

Add a complete .dockerignore file — exclude bin, obj, .git, .env

Configure health checks with realistic start_period for .NET cold start

Set CPU and memory limits — 256Mi+ memory for stable .NET startup

Scan images for vulnerabilities with Trivy or Docker Scout before deploying

Log to stdout not files — let Docker's logging driver handle rotation

Final Thoughts

Containerizing .NET 10 applications with Docker transforms your development workflow and deployment reliability. From local development to global deployment, your application runs consistently, scales elastically, and integrates seamlessly with modern cloud-native infrastructure. The investment in containerization pays dividends in deployment velocity, infrastructure cost, and team productivity.

Docker .NET 10 ASP.NET Core DevOps CI/CD Containers Backend Tutorial
AS

Written by

Adnan Shaukat

Senior .NET Backend Developer with 8+ years building enterprise systems. Writing daily at codewithadnanshaukat.blogspot.com

Comments

Popular posts from this blog

What is Agentic AI? A Complete Developer Guide for 2026

EF Core 10 Performance Tips — 12 Ways to Make Your Queries Blazing Fast

.NET 10 New Features Performance and Changes — Complete Guide