Docker for .NET Developers — Complete Beginner to Production Guide
"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.
Table of Contents
- Why containerize .NET applications?
- Your first Dockerfile — single stage
- Multi-stage builds — the production way
- .dockerignore — what to exclude
- Layer caching — faster builds
- Docker Compose — full local environment
- Security best practices
- Health checks and resource limits
- CI/CD integration
- Production checklist
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.ymlservices: 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.
DockerfileRUN 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.ymlservices: 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.csbuilder.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 Actionsname: 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.
Written by
Adnan Shaukat
Senior .NET Backend Developer with 8+ years building enterprise systems. Writing daily at codewithadnanshaukat.blogspot.com
Comments
Post a Comment