Learn Docker for .NET developers from scratch: build images, run ASP.NET Core containers, and use docker-compose with SQL Server. Start containerizing today.
If you have ever heard "it works on my machine" (or said it yourself), Docker is the fix. This guide to Docker for .NET developers starts from zero: what containers and images actually are, how to write a production-grade Dockerfile for an ASP.NET Core app, and how to wire up docker-compose with SQL Server so your whole stack starts with one command. Every example runs on .NET 8/9 with Windows, macOS, or Linux as the host.
Why Docker Matters for .NET Developers
Historically, .NET meant IIS on Windows Server. Since .NET Core, the runtime is cross-platform and Microsoft publishes official Linux images on the Microsoft Container Registry (MCR). That changes deployment fundamentally:
- Reproducibility: the image contains the exact runtime, native libraries, and configuration. Dev, CI, staging, and production run the same bytes.
- Isolation: two apps needing different .NET versions coexist on one host without side-by-side runtime installs.
- Cloud-native by default: Azure Container Apps, AKS, AWS ECS, and Google Cloud Run all consume container images. Docker is the on-ramp to Kubernetes.
- Cheaper onboarding: a new team member runs
docker compose upinstead of following a 40-step wiki page.
Containers vs Images: The Mental Model
These two terms are confused constantly, so let's nail them down:
- An image is an immutable, layered filesystem snapshot plus metadata (entrypoint, environment variables, exposed ports). Think of it as a class.
- A container is a running (or stopped) instance of an image with its own writable layer, process namespace, and network interface. Think of it as an object.
Images are built from layers. Each Dockerfile instruction creates a layer, and Docker caches layers aggressively. Understanding layers is the difference between a 30-second build and a 5-minute one, which we will exploit in the multi-stage Dockerfile below.
Unlike a virtual machine, a container shares the host kernel. There is no guest OS to boot, so containers start in milliseconds and a typical ASP.NET Core image is around 100 MB rather than several gigabytes.
Prerequisites
- .NET 8 SDK or later (
dotnet --version) - Docker Desktop (Windows/macOS) or Docker Engine (Linux). On Windows, enable the WSL 2 backend for the best performance.
- Verify with
docker --versionanddocker compose version(note:docker composewith a space is the modern V2 CLI;docker-composewith a hyphen is the legacy Python tool).
Step 1: Create a Minimal ASP.NET Core API
We will containerize a small Minimal API that exposes a health endpoint and a products endpoint backed by EF Core. Start with the project:
dotnet new webapi -n ProductApi --no-openapi
cd ProductApi
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
Replace Program.cs with the following:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Connection string comes from configuration, which Docker will override via env vars.
var connectionString = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("Connection string 'Default' not found.");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
var app = builder.Build();
// Apply migrations / create schema on startup (fine for demos; use a migration job in prod).
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
app.MapGet("/health", () => Results.Ok(new { status = "healthy", time = DateTime.UtcNow }));
app.MapGet("/products", async (AppDbContext db) =>
await db.Products.AsNoTracking().ToListAsync());
app.MapPost("/products", async (Product product, AppDbContext db) =>
{
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", product);
});
app.Run();
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
}
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
Notice we read the connection string from configuration rather than hard-coding it. ASP.NET Core maps the environment variable ConnectionStrings__Default (double underscore) to ConnectionStrings:Default, which is exactly how Docker will inject it.
Step 2: Write a Multi-Stage Dockerfile for .NET
A naive Dockerfile copies your source into an SDK image and runs dotnet run. That ships a 900 MB image containing compilers, NuGet caches, and your source code. The correct approach is a multi-stage build: compile in the SDK image, then copy only the published output into the slim runtime image. Create a file named Dockerfile (no extension) in the project folder:
# ---------- Stage 1: build ----------
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy only the project file first so 'dotnet restore' is cached
# until your package references actually change.
COPY ProductApi.csproj ./
RUN dotnet restore
# Now copy the rest of the source and publish.
COPY . ./
RUN dotnet publish -c Release -o /app/publish --no-restore
# ---------- Stage 2: runtime ----------
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# Run as the built-in non-root user shipped in .NET 8+ images.
USER app
# .NET 8+ images listen on 8080 by default (not 80).
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
COPY --from=build /app/publish ./
ENTRYPOINT ["dotnet", "ProductApi.dll"]
Why this Dockerfile is structured this way
- Restore before copying everything. Docker invalidates the cache from the first changed layer onward. Because
.csprojchanges rarely and source changes constantly, splitting the copy meansdotnet restoreis skipped on almost every build. This alone routinely cuts CI build times in half. aspnetvssdkimage. The runtime image is roughly 220 MB versus 900 MB+ for the SDK. Smaller images pull faster, start faster, and have a smaller attack surface.- Non-root user. .NET 8 images include a user named
app(UID 1654). Running as root inside a container is a common security finding in audits. - Port 8080. Since .NET 8, official images default to port 8080 precisely because non-root users cannot bind ports below 1024. If you are upgrading from .NET 6/7 Dockerfiles that used port 80, this is the change that breaks things.
Add a .dockerignore file so bin/, obj/, and .git/ never bloat the build context:
bin/
obj/
.git/
.vs/
**/*.user
Step 3: Build the Image and Run the Container
docker build -t productapi:1.0 .
docker run --rm -p 5000:8080 -e ConnectionStrings__Default="..." productapi:1.0
Breaking that down: -t tags the image, -p 5000:8080 maps host port 5000 to container port 8080, -e injects an environment variable, and --rm deletes the container when it stops. Browse to http://localhost:5000/health and you have a containerized .NET API.
Useful commands while you explore:
docker ps— list running containersdocker logs -f <container>— stream stdout (ASP.NET Core logs go here by default)docker exec -it <container> /bin/bash— open a shell inside the containerdocker image ls— see image sizes (compare your multi-stage image to an SDK-based one)
Step 4: Docker Compose with ASP.NET Core and SQL Server
A real app needs a database, and running two containers by hand with matching networks and environment variables gets tedious fast. Docker Compose declares your entire stack in one YAML file. Create docker-compose.yml next to your Dockerfile:
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:8080"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Default: "Server=db;Database=ProductDb;User Id=sa;Password=${SA_PASSWORD};TrustServerCertificate=True;"
depends_on:
db:
condition: service_healthy
db:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: ${SA_PASSWORD}
ports:
- "1433:1433"
volumes:
- sqldata:/var/opt/mssql
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
volumes:
sqldata:
Create a .env file beside it (and add it to .gitignore):
SA_PASSWORD=YourStrong!Passw0rd
Then start everything:
docker compose up --build
What is happening in this compose file
- Service names are DNS names. The API connects to
Server=db, notlocalhost. Compose creates a private network where each service resolves by name. Usinglocalhostinside a container refers to the container itself, which is the number-one docker-compose mistake. depends_onwithcondition: service_healthy. Plaindepends_ononly waits for the container to start, not for SQL Server to be ready to accept connections (which takes 10–30 seconds). The health check makes Compose wait untilsqlcmdsucceeds, so yourEnsureCreated()call doesn't throw on startup.- Named volume for data. Without
sqldata, everydocker compose downwipes your database. The volume persists until you explicitly rundocker compose down -v. - Secrets via
.env. Passwords never go in the YAML you commit. For production, use Docker secrets, Azure Key Vault, or your orchestrator's secret store. - Apple Silicon note: the SQL Server image is x64-only. On M-series Macs, add
platform: linux/amd64to thedbservice or usemcr.microsoft.com/azure-sql-edgeinstead.
Best Practices for Docker and .NET
Pin your base image versions
Use aspnet:8.0 rather than aspnet:latest. A surprise major-version bump in CI is a bad way to discover breaking changes. For maximum reproducibility, pin the digest (@sha256:...).
Choose the right image variant
aspnet:8.0— Debian-based, safest default.aspnet:8.0-alpine— roughly 110 MB, but uses musl libc; test globalization and native dependencies carefully.aspnet:8.0-noble-chiseled— Ubuntu "chiseled" images with no shell or package manager. Smallest and most secure, but you cannotdocker execinto them for debugging.
Add a health check to the image
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:8080/health || exit 1
Orchestrators use this to restart unhealthy instances automatically. Pair it with builder.Services.AddHealthChecks() in ASP.NET Core to report real dependency status.
Let the SDK build the image for you
Since .NET 7, you can publish directly to a container without a Dockerfile at all:
dotnet publish -c Release -p:PublishProfile=DefaultContainer -p:ContainerImageTag=1.0
This is excellent for simple services and CI pipelines. Use a hand-written Dockerfile when you need custom base images, native tooling, or fine-grained layer control.
Handle graceful shutdown
Docker sends SIGTERM and waits 10 seconds before killing the process. ASP.NET Core's generic host handles this correctly out of the box, but always use the exec form of ENTRYPOINT (JSON array syntax, as shown above). The shell form wraps your app in /bin/sh, which swallows the signal and your app gets hard-killed mid-request.
Common Pitfalls and How to Fix Them
- "Connection refused" from the API to SQL Server: you used
localhostinstead of the service name, or you didn't wait for the health check. - App runs but the browser can't reach it: the app bound to
localhostinside the container. EnsureASPNETCORE_URLS=http://+:8080so Kestrel listens on all interfaces. - Port 80 vs 8080 after upgrading to .NET 8: update your
EXPOSE, port mappings, and any Kubernetes manifests. - HTTPS redirection warnings: containers usually terminate TLS at a reverse proxy or ingress. Remove
app.UseHttpsRedirection()for the container build or configure forwarded headers properly. - Build is slow every time: you copied the whole source before
dotnet restore, or you forgot.dockerignoresobin/objinvalidate the cache. - Invariant globalization errors on Alpine: either install
icu-libsor setDOTNET_SYSTEM_GLOBALIZATION_INVARIANT=trueif culture-specific formatting isn't needed. - Time zone is UTC: containers default to UTC. Store UTC in the database and convert at the edges—this is good practice anyway.
Conclusion: Key Takeaways on Docker for .NET Developers
Mastering Docker for .NET developers comes down to a handful of ideas that pay off every day:
- An image is an immutable template; a container is a running instance. Layers are cached top-down, so order your Dockerfile from least- to most-frequently changing.
- Always use a multi-stage build: compile in
sdk, run inaspnet. Restore before copying source. - .NET 8+ images run as non-root on port 8080—plan for it.
- Docker Compose turns your app plus SQL Server into a single
docker compose up, with service names as hostnames, health-gated startup, and volumes for persistence. - Configure via environment variables (
ConnectionStrings__Default), keep secrets in.envor a vault, and pin your base images.
From here, the natural next steps are pushing your image to a registry (Azure Container Registry, Docker Hub, GitHub Container Registry), adding a GitHub Actions workflow that runs docker build on every pull request, and eventually deploying to Azure Container Apps or Kubernetes. The Dockerfile you wrote today works unchanged in all of them.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment