Back to Notes

Docker Best Practices for .NET Applications

Docker.NETDevOpsContainers

Docker Best Practices for .NET Applications

Essential Docker practices learned from containerizing .NET applications in production environments.

Multi-Stage Builds

Reduce image size by separating build and runtime:

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Copy only csproj first (better layer caching)
COPY ["MyApp/MyApp.csproj", "MyApp/"]
RUN dotnet restore "MyApp/MyApp.csproj"

# Copy everything else and build
COPY . .
WORKDIR "/src/MyApp"
RUN dotnet build "MyApp.csproj" -c Release -o /app/build

# Stage 2: Publish
FROM build AS publish
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish /p:UseAppHost=false

# Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Result: Image size reduced from 2.1GB to 210MB.

Security Hardening

Run as Non-Root User

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app

# Create non-root user
RUN adduser --disabled-password --gecos "" appuser && \
    chown -R appuser /app

USER appuser

COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Use Minimal Base Images

# ✅ Prefer alpine or slim variants
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS final

# Even better: distroless (no shell, minimal attack surface)
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-jammy-chiseled AS final

Scan for Vulnerabilities

# Use Docker Scout or Trivy
docker scout cves myapp:latest
trivy image myapp:latest

Performance Optimization

Layer Caching Strategy

# ❌ Bad: Changes to code invalidate all layers
COPY . .
RUN dotnet restore
RUN dotnet build

# ✅ Good: Restore dependencies first
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet build

Optimize for ReadyToRun

# Compile to native code for faster startup
RUN dotnet publish -c Release -o /app/publish \
    /p:PublishReadyToRun=true \
    /p:PublishSingleFile=false

Use .dockerignore

# .dockerignore
bin/
obj/
*.md
.git/
.vs/
.vscode/
**/.DS_Store
**/appsettings.Development.json

Health Checks

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=publish /app/publish .

# Add health check
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1

ENTRYPOINT ["dotnet", "MyApp.dll"]

In C# code:

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>()
    .AddCheck("api", () => HealthCheckResult.Healthy());

app.MapHealthChecks("/health");

Environment Configuration

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final

ENV ASPNETCORE_URLS=http://+:8080 \
    ASPNETCORE_ENVIRONMENT=Production \
    DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Use Docker Compose for local development:

version: '3.8'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__Default=Server=db;Database=MyApp;User=sa;Password=YourPass123!
    depends_on:
      - db
    
  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=YourPass123!
    ports:
      - "1433:1433"
    volumes:
      - sqldata:/var/opt/mssql

volumes:
  sqldata:

Logging

Configure for container environments:

builder.Logging.ClearProviders();
builder.Logging.AddConsole(); // Stdout for container logs

// Use structured logging
builder.Services.AddSerilog((services, lc) => lc
    .WriteTo.Console(
        outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
    .ReadFrom.Configuration(builder.Configuration));

Resource Limits

# docker-compose.yml
services:
  api:
    build: .
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 2G
        reservations:
          cpus: '0.5'
          memory: 512M

Or in Dockerfile:

# Runtime limits
ENV DOTNET_GCHeapHardLimit=800000000  # ~800MB
ENV DOTNET_ThreadPool_UnfairSemaphoreSpinLimit=6

Build Arguments for Flexibility

ARG BUILD_CONFIGURATION=Release
ARG RUNTIME_VERSION=8.0

FROM mcr.microsoft.com/dotnet/sdk:${RUNTIME_VERSION} AS build
WORKDIR /src

COPY . .
RUN dotnet build -c ${BUILD_CONFIGURATION} -o /app/build

Build with custom args:

docker build --build-arg BUILD_CONFIGURATION=Debug -t myapp:debug .

Networking Best Practices

Use Bridge Networks

version: '3.8'

networks:
  frontend:
  backend:

services:
  api:
    networks:
      - frontend
      - backend
  
  db:
    networks:
      - backend  # Not exposed to frontend

Service Discovery

// Use service names as hostnames
builder.Services.AddHttpClient<IOrderService, OrderService>(client =>
{
    client.BaseAddress = new Uri("http://order-service:8080");
});

Debugging in Containers

# Development Dockerfile with debugger
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
RUN apt-get update && apt-get install -y unzip && \
    curl -sSL https://aka.ms/getvsdbgsh | \
    /bin/sh /dev/stdin -v latest -l /vsdbg

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet build -c Debug

FROM base AS final
WORKDIR /app
COPY --from=build /src/bin/Debug/net8.0/ .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Launch config for VS Code:

{
  "type": "coreclr",
  "request": "attach",
  "processId": "${command:pickRemoteProcess}",
  "pipeTransport": {
    "pipeProgram": "docker",
    "pipeArgs": ["exec", "-i", "myapp_container"],
    "debuggerPath": "/vsdbg/vsdbg",
    "pipeCwd": "${workspaceRoot}"
  }
}

CI/CD Integration

# GitHub Actions example
name: Docker Build and Push

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: user/app:${{ github.sha }},user/app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Production Checklist

  • ✅ Use multi-stage builds
  • ✅ Run as non-root user
  • ✅ Scan for vulnerabilities
  • ✅ Add health checks
  • ✅ Configure resource limits
  • ✅ Use specific version tags (not :latest)
  • ✅ Implement proper logging
  • ✅ Set up monitoring (Prometheus metrics)
  • ✅ Use .dockerignore
  • ✅ Enable ReadyToRun compilation
  • ✅ Test in container before deploy
  • ✅ Use secrets management (not env vars)

Monitoring

// Expose metrics for Prometheus
builder.Services.AddHealthChecksUI()
    .AddInMemoryStorage();

app.UseHealthChecks("/health");
app.UseHealthChecksUI(config =>
{
    config.UIPath = "/health-ui";
});

// Add prometheus metrics
app.UseHttpMetrics();
app.MapMetrics();

Dockerfile addition:

EXPOSE 8080
EXPOSE 9090  # Metrics port

Common Mistakes to Avoid

  1. Not using .dockerignore → Slow builds, large images
  2. Running as root → Security risk
  3. Using :latest tag → Unpredictable deployments
  4. No health checks → Poor orchestration
  5. Hardcoded secrets → Security breach
  6. Not optimizing layers → Slow builds, large images

Quick Commands

# Build and tag
docker build -t myapp:1.0.0 .

# Run with port mapping
docker run -p 8080:8080 myapp:1.0.0

# Run with env file
docker run --env-file .env myapp:1.0.0

# View logs
docker logs -f container_name

# Execute command in running container
docker exec -it container_name bash

# Clean up
docker system prune -a --volumes

# Inspect image layers
docker history myapp:1.0.0

Key Takeaways

  • Multi-stage builds reduce image size by 90%+
  • Security should be built in from the start
  • Layer caching dramatically speeds up builds
  • Health checks enable self-healing systems
  • Resource limits prevent resource starvation

Docker isn't just about packaging—it's about creating reproducible, secure, and efficient deployments.


What Docker practices have worked for you? Let's discuss!