Docker and .NET have come a long way together. Microsoft now ships official .NET images, Visual Studio has first-class Docker support, and containerized .NET apps routinely run in production at scale. But if you’ve never containerized a .NET application before, the learning curve is steeper than the tutorials let on.
This guide starts from scratch — what Docker actually is, why .NET developers specifically benefit from it, and how to go from a fresh ASP.NET Core app to a production-ready container setup.
Table of Contents
- Why .NET Developers Need Docker
- Your First .NET Dockerfile
- Multi-Stage Builds: Smaller, Faster Images
- Docker Compose for .NET + SQL Server + Redis
- Local Development Workflow
- Environment Variables and Configuration
- Production Hardening Tips
- FAQ
Why .NET Developers Need Docker
The old “works on my machine” problem is real. A junior dev on Windows, a senior on Mac, and CI running Ubuntu — three environments, three potential failure modes. Docker eliminates that variable entirely.
Beyond that, .NET-specific reasons to containerize:
- Runtime version pinning: Pin to .NET 8.0.3 exactly — no more “what runtime does production have?” surprises.
- Side-by-side deployments: Run .NET 6 and .NET 8 apps on the same host without version conflicts.
- Kubernetes readiness: Every major cloud Kubernetes offering (AKS, EKS, GKE) expects container images.
- Dependency bundling: No more “install this NuGet feed on the server” — everything is in the image.
Prerequisites
- Docker Desktop installed (Windows/Mac) or Docker Engine (Linux)
- .NET 8 SDK
- Basic command-line familiarity
Your First .NET Dockerfile
Create a new ASP.NET Core app and add a Dockerfile to the project root:
dotnet new webapi -n MyApi
cd MyApi
Now create Dockerfile:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApi.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
Build and run it:
docker build -t myapi:latest .
docker run -p 8080:8080 myapi:latest
Hit http://localhost:8080/weatherforecast — your .NET API is running in a container.
Understanding the Base Images
Microsoft publishes several .NET images. Choose carefully:
mcr.microsoft.com/dotnet/sdk:8.0— Full SDK for building. ~700MB. Never use this as runtime.mcr.microsoft.com/dotnet/aspnet:8.0— ASP.NET Core runtime. ~200MB. Use for web apps.mcr.microsoft.com/dotnet/runtime:8.0— Base runtime. ~130MB. Use for console apps/workers.mcr.microsoft.com/dotnet/runtime-deps:8.0— Just native dependencies. ~40MB. Use with self-contained AOT apps.
Multi-Stage Builds: Smaller, Faster Images
The Dockerfile above already uses multi-stage builds — that’s the AS build and AS final pattern. Here’s why it matters:
Without multi-stage builds, your final image would contain the entire .NET SDK (~700MB). With multi-stage builds, only the compiled output is copied to the final image (~200MB). That’s a 3x size difference — multiply that across every push to your registry.
Optimized Multi-Stage Dockerfile for Production
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy csproj and restore first (layer caching optimization)
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
# Copy everything and build
COPY . .
WORKDIR "/src/MyApi"
RUN dotnet build "MyApi.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
RUN dotnet publish "MyApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
Layer Caching Trick
Notice that COPY ["MyApi.csproj", "MyApi/"] and RUN dotnet restore happen before copying source files. This is intentional — the restore layer only invalidates when your .csproj changes. If only your C# files changed, Docker reuses the cached restore layer. Builds go from 45 seconds to 8 seconds.
Docker Compose for .NET + SQL Server + Redis
Real apps don’t run in isolation. Here’s a production-realistic docker-compose.yml for a .NET API with SQL Server and Redis:
version: '3.8'
services:
api:
build:
context: .
dockerfile: MyApi/Dockerfile
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=MyDb;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True
- ConnectionStrings__Redis=redis:6379
depends_on:
sqlserver:
condition: service_healthy
redis:
condition: service_started
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=YourStrong!Passw0rd
- MSSQL_PID=Developer
ports:
- "1433:1433"
volumes:
- sqlserver_data:/var/opt/mssql
healthcheck:
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "YourStrong!Passw0rd" -Q "SELECT 1" || exit 1
interval: 10s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
sqlserver_data:
redis_data:
Start everything: docker compose up -d. Tear it down: docker compose down. Your entire dev environment in one command.
Running EF Core Migrations in Docker
# Run migrations against the containerized database
docker compose exec api dotnet ef database update
# Or run as a separate migration service
docker compose run --rm api dotnet ef database update
Local Development Workflow
Full rebuilds on every code change are painful. Use volume mounts for hot reload during development:
# docker-compose.override.yml (development only, not committed to prod)
version: '3.8'
services:
api:
build:
target: build # stop at build stage, don't publish
volumes:
- ./MyApi:/src/MyApi # mount source
environment:
- DOTNET_USE_POLLING_FILE_WATCHER=1
command: dotnet watch run --project MyApi/MyApi.csproj
Now docker compose up starts with hot reload — save a file, the app restarts inside the container automatically.
Debugging in Docker with VS Code
Add to your docker-compose.override.yml:
services:
api:
environment:
- VSDBG_VERSION=latest
ports:
- "4024:4024" # debugger port
Then use the VS Code Docker extension’s “Attach to .NET” feature to attach a full debugger to the running container. Breakpoints work exactly as expected.
Environment Variables and Configuration
.NET’s configuration system maps environment variables to appsettings.json keys using double-underscore as section separator:
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "..."
}
}
// Equivalent environment variable
ConnectionStrings__DefaultConnection=Server=...
Using .env Files with Docker Compose
# .env (never commit this)
SA_PASSWORD=YourStrong!Passw0rd
JWT_SECRET=your-super-secret-key
# docker-compose.yml — reference .env variables
services:
sqlserver:
environment:
- SA_PASSWORD=${SA_PASSWORD}
Docker Secrets for Production
In production (Docker Swarm or Kubernetes), use secrets instead of environment variables for sensitive values. .NET reads secrets from /run/secrets/ by convention when configured:
builder.Configuration.AddKeyPerFile("/run/secrets", optional: true);
Production Hardening Tips
Run as Non-Root User
FROM mcr.microsoft.com/dotnet/aspnet:8.0
# .NET 8 images include a non-root 'app' user
USER app
WORKDIR /app
Health Checks
# In Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
// In Program.cs — map a health endpoint
app.MapHealthChecks("/health");
Minimize Image Size
- Use
-alpinevariants where available (though .NET alpine images have limitations) - Add a
.dockerignorefile to excludebin/,obj/,.git/, and*.mdfrom the build context - Consider Native AOT for startup-time-critical apps (cuts image size by 60%+ and startup to milliseconds)
# .dockerignore
bin/
obj/
.git/
.vs/
*.user
*.md
Dockerfile*
docker-compose*
[INTERNAL_LINK: ASP.NET Core deployment to Azure Container Apps]
FAQ
Should I use Docker Desktop or Docker Engine on Windows?
Docker Desktop is the easiest path on Windows — it includes the Docker Engine, CLI, and Docker Compose in one installer. On Windows, you’ll want WSL2 backend enabled for better performance. Docker Engine alone (via WSL2) is an option if you want to avoid Docker Desktop’s licensing requirements for larger teams.
How do I access the containerized SQL Server from SSMS?
With the ports: - "1433:1433" mapping in place, connect SSMS to localhost,1433 with SQL authentication using the SA credentials you set. It works exactly like a locally installed SQL Server from the client’s perspective.
Why is my .NET Docker image so large?
If you’re using the SDK image as your final image, that’s why. Switch to a multi-stage build (shown above) and use the aspnet or runtime image for the final stage. Also add a .dockerignore to exclude build artifacts from the context.
Can I run Windows containers for .NET?
Yes, but Linux containers are strongly preferred for .NET in 2025. Linux images are smaller, faster to pull, and supported by all cloud container services. Windows containers are niche — use them only if you have a hard dependency on Windows-specific APIs (COM, registry, etc.).
How do I handle database migrations at startup?
The safest pattern is to run migrations as an init job or a startup check, not inline in your app startup. For smaller apps, using context.Database.MigrateAsync() at startup is acceptable. For production systems, run migrations as a separate step in your CI/CD pipeline before deploying the new container.
Docker with .NET is a well-worn path in 2025. The tooling is mature, the official images are well-maintained, and the development experience with hot reload and debugger attach is genuinely good. Start with the multi-stage Dockerfile, get Compose working locally with your dependencies, and you’ll have a setup that scales from your laptop to Kubernetes without changes.
[INTERNAL_LINK: .NET 8 deployment to Azure Kubernetes Service]

Leave a Reply