Skip to main content

Docker for .NET Developers: Containers & Compose Tutorial

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 up instead 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 --version and docker compose version (note: docker compose with a space is the modern V2 CLI; docker-compose with 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 .csproj changes rarely and source changes constantly, splitting the copy means dotnet restore is skipped on almost every build. This alone routinely cuts CI build times in half.
  • aspnet vs sdk image. 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 containers
  • docker logs -f <container> — stream stdout (ASP.NET Core logs go here by default)
  • docker exec -it <container> /bin/bash — open a shell inside the container
  • docker 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, not localhost. Compose creates a private network where each service resolves by name. Using localhost inside a container refers to the container itself, which is the number-one docker-compose mistake.
  • depends_on with condition: service_healthy. Plain depends_on only 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 until sqlcmd succeeds, so your EnsureCreated() call doesn't throw on startup.
  • Named volume for data. Without sqldata, every docker compose down wipes your database. The volume persists until you explicitly run docker 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/amd64 to the db service or use mcr.microsoft.com/azure-sql-edge instead.

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 cannot docker exec into 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 localhost instead 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 localhost inside the container. Ensure ASPNETCORE_URLS=http://+:8080 so 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 .dockerignore so bin/obj invalidate the cache.
  • Invariant globalization errors on Alpine: either install icu-libs or set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true if 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 in aspnet. 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 .env or 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...