Articles › Git & DevOps

Docker for .NET Developers: Containerizing ASP.NET Core and SQL Server

Learn Docker from a .NET developer's perspective: images and containers, a multi-stage Dockerfile for ASP.NET Core, docker compose with SQL Server, volumes, configuration and production tips.

"It works on my machine" stops being an excuse with Docker. A container packages your application with exactly the runtime and dependencies it needs, so it runs the same on your laptop, a teammate's computer, a CI server and production. This guide explains Docker from a .NET developer's point of view and ends with a complete ASP.NET Core + SQL Server setup.

Core concepts in five minutes

  • Image — a read-only template: OS layer + .NET runtime + your published app. Built from a Dockerfile.
  • Container — a running instance of an image, isolated from other processes.
  • Registry — a store for images (Docker Hub, GitHub Container Registry, a private registry).
  • Volume — persistent storage that survives container restarts (databases, uploads).
  • Network — lets containers talk to each other by name.

Images are built in layers. Docker caches each layer, so unchanged steps aren't repeated — which is why the order of instructions in a Dockerfile matters.

A multi-stage Dockerfile for ASP.NET Core

# ---------- build stage ----------
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# 1) copy project files first and restore — cached until dependencies change
COPY Portfolio.sln ./
COPY src/Portfolio.Api/Portfolio.Api.csproj src/Portfolio.Api/
COPY src/Portfolio.Application/Portfolio.Application.csproj src/Portfolio.Application/
COPY src/Portfolio.Infrastructure/Portfolio.Infrastructure.csproj src/Portfolio.Infrastructure/
RUN dotnet restore src/Portfolio.Api/Portfolio.Api.csproj

# 2) copy the rest and publish
COPY . .
RUN dotnet publish src/Portfolio.Api/Portfolio.Api.csproj -c Release -o /app/publish /p:UseAppHost=false

# ---------- runtime stage ----------
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
USER app
ENTRYPOINT ["dotnet", "Portfolio.Api.dll"]

Why two stages?

  • The SDK image (~800 MB) contains compilers needed only to build.
  • The ASP.NET runtime image (~220 MB, or much smaller with Alpine/chiseled variants) contains only what's needed to run.
  • The final image ships just the runtime and your published output — smaller, faster to deploy and with a smaller attack surface.

USER app runs the process as a non-root user (available in .NET 8+ images) — an easy security win.

Add a .dockerignore so build contexts stay small and secrets don't leak into images:

bin/
obj/
.git/
.vs/
node_modules/
**/appsettings.Development.json
*.user

Build and run

docker build -t portfolio-api:1.0 .
docker run -d --name api -p 5000:8080 -e ASPNETCORE_ENVIRONMENT=Production portfolio-api:1.0
docker logs -f api

-p 5000:8080 maps port 5000 on your machine to port 8080 inside the container.

Containerize without a Dockerfile

Since .NET 8, the SDK can build container images directly:

dotnet publish src/Portfolio.Api -c Release /t:PublishContainer -p ContainerImageTags=1.0

It's great for simple services; a Dockerfile gives you more control for complex builds.

docker compose: API + SQL Server together

services:
  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "${DB_PASSWORD}"
    ports:
      - "1433:1433"
    volumes:
      - sqldata:/var/opt/mssql
    healthcheck:
      test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P $$MSSQL_SA_PASSWORD -Q 'SELECT 1' || exit 1"]
      interval: 10s
      retries: 10

  api:
    build: .
    ports:
      - "5000:8080"
    environment:
      ASPNETCORE_ENVIRONMENT: Production
      ConnectionStrings__Default: "Server=db;Database=Portfolio;User Id=sa;Password=${DB_PASSWORD};TrustServerCertificate=True"
      Jwt__Key: "${JWT_KEY}"
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - uploads:/app/wwwroot/uploads

volumes:
  sqldata:
  uploads:
docker compose up -d --build
docker compose logs -f api
docker compose down          # stop (volumes are kept)

Key points:

  • Containers reach each other by service name: the connection string uses Server=db.
  • Configuration comes from environment variables (__ for nested keys), and secrets come from a .env file that's never committed.
  • Volumes keep the database and uploaded files when containers are recreated.
  • The health check plus depends_on: condition: service_healthy waits until SQL Server is actually ready.

Applying EF Core migrations

Options, from simplest to most robust:

  1. Call db.Database.Migrate() at startup (fine for single-instance apps).
  2. Run a one-off container: docker compose run --rm api dotnet Portfolio.Api.dll --migrate.
  3. Generate an idempotent SQL script or a migration bundle in CI and run it as a deployment step.

Development workflow tips

  • Use Docker for dependencies (SQL Server, Redis, Seq) even if you run the API from your IDE — no local installs needed.
  • docker compose -f docker-compose.yml -f docker-compose.override.yml separates dev and prod settings.
  • Clean up regularly: docker system prune (careful — read what it removes).

Production checklist

  • Multi-stage build, small runtime image, non-root user
  • .dockerignore excludes secrets and build artifacts
  • Configuration and secrets via environment variables or a secret store
  • Health checks (app.MapHealthChecks("/health")) used by the orchestrator
  • Logs written to stdout (collected by Docker/Kubernetes)
  • Persistent data in volumes or managed services, never inside the container
  • Image tags with versions (1.4.2), not just latest
  • Behind a reverse proxy (Nginx, Traefik, a cloud load balancer) handling HTTPS

When running behind a proxy, enable forwarded headers so ASP.NET Core knows the original scheme and client IP:

app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});

Summary

Docker packages your .NET application and its runtime into a portable image. Use a multi-stage Dockerfile to build with the SDK and run on the slim ASP.NET image, describe your full environment — API, SQL Server, volumes and networks — with docker compose, inject configuration through environment variables, and follow a short production checklist. The result: identical, reproducible environments from development to production.

FAQ

Do I need Docker to deploy ASP.NET Core?

No. IIS, Linux services and Azure App Service all work without containers. Docker shines when you want reproducible environments, multiple services or Kubernetes.

Should the database run in a container in production?

For small projects it can. Many teams prefer a managed database or a dedicated server for backups, performance and reliability.

Windows or Linux containers for .NET?

Use Linux containers for ASP.NET Core — they're smaller, faster and more widely supported. Windows containers are mainly for legacy .NET Framework apps.