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
- Not using .dockerignore → Slow builds, large images
- Running as root → Security risk
- Using :latest tag → Unpredictable deployments
- No health checks → Poor orchestration
- Hardcoded secrets → Security breach
- 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!