Containerizing .NET Apps: From Dockerfile to Production

Containers have become the default deployment unit for modern applications. If you're building .NET apps and not containerizing them yet, here's everything you need to go from zero to production.

Why Containers?

The pitch is simple: build once, run anywhere. But the real benefits go deeper:

The Multi-Stage Dockerfile

A naive Dockerfile copies everything and runs dotnet publish. A production Dockerfile uses multi-stage builds to keep the final image small:

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

# Copy project files first (better caching)
COPY *.csproj .
RUN dotnet restore

# Copy everything else and publish
COPY . .
RUN dotnet publish -c Release -o /app --no-restore

# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine
WORKDIR /app
COPY --from=build /app .

# Run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

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

Image Size Comparison

The multi-stage approach dramatically reduces image size:

ApproachImage Size
SDK image (naive)~900 MB
ASP.NET runtime image~220 MB
Alpine runtime image~110 MB
Alpine + trimmed publish~75 MB

That's a 12x reduction from the naive approach.

The Build Pipeline

Here's how a typical CI/CD pipeline looks with containerized .NET:

flowchart LR
    subgraph CI
        A[Push Code] --> B[Restore & Build]
        B --> C[Run Tests]
        C --> D[dotnet publish]
        D --> E[Docker Build]
        E --> F[Push to Registry]
    end
    subgraph CD
        F --> G[Pull Image]
        G --> H[Health Check]
        H --> I[Rolling Deploy]
    end

Docker Compose for Development

For local development, Docker Compose orchestrates your app alongside its dependencies:

services:
  api:
    build: .
    ports:
      - "5000:8080"
    environment:
      - ConnectionStrings__Default=Host=db;Database=myapp;Username=postgres;Password=secret
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:
# One command to start everything
docker compose up -d

# View logs
docker compose logs -f api

# Tear down
docker compose down

Health Checks

Always add health checks to your .NET app. They're critical for orchestrators:

builder.Services.AddHealthChecks()
    .AddNpgSql(connectionString, name: "database")
    .AddCheck("self", () => HealthCheckResult.Healthy());

app.MapHealthChecks("/healthz", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

Then reference it in your Dockerfile:

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/healthz || exit 1

GitHub Actions Workflow

Automate the whole thing with GitHub Actions:

name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Production Checklist

Before deploying containers to production, verify:

Key Takeaways

Containerizing .NET apps isn't just about writing a Dockerfile. It's about building a reproducible, secure, and efficient deployment pipeline. Start with the multi-stage Dockerfile, add health checks, and automate with CI/CD. Everything else follows.