Dockerizing ASP.NET Core: From Basics to Production

A complete guide to containerizing ASP.NET Core applications. Learn standard patterns, security best practices, and how to use Chiseled Ubuntu images.

FSI
Full Stack Insights
4 min read...

Introduction

Containerizing an ASP.NET Core application is often treated as a checkbox. You might be tempted to just right-click your project in Visual Studio and select Add > Docker Support. While that wizard gives you a decent starting point, it often hides the complexity you need to understand when things break in production.

In this guide, we're going to bypass the wizards. We will manually build a production-grade Dockerfile for a .NET 8 Web API, understanding every single line we add.

Phase 1: The Standard Multi-Stage Build

Microsoft provides excellent base images. The standard approach uses a Multi-Stage Build: one stage to compile the code (which requires the heavy SDK) and another stage to run it (which only needs the lightweight Runtime).

Here is the "Gold Standard" Dockerfile for a project named MyWebApi:

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

# Copy csproj and restore as distinct layers
COPY ["MyWebApi/MyWebApi.csproj", "MyWebApi/"]
RUN dotnet restore "MyWebApi/MyWebApi.csproj"

# Copy everything else and build
COPY . .
WORKDIR "/src/MyWebApi"
RUN dotnet build "MyWebApi.csproj" -c Release -o /app/build

# Publish the application
FROM build AS publish
RUN dotnet publish "MyWebApi.csproj" -c Release -o /app/publish /p:UseAppHost=false

# STAGE 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyWebApi.dll"]

Why this works:

  1. Cache Efficiency: We copy the .csproj and run dotnet restore before copying the rest of the code. Docker caches this layer. If you change a .cs file but not your dependencies, the restore step is skipped.
  2. Size: The final image only contains the compiled binaries and the runtime, not the source code or the build tools.

Phase 2: Configuration & The .dockerignore

1. The .dockerignore File

This is the most common mistake I see in code reviews. If you skip this, Docker will copy your local bin/ and obj/ folders into the build context. This bloats the build time and can cause weird compilation errors because the container tries to use your local Windows/macOS binaries.

Create a .dockerignore file in the root immediately:

**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.vs
**/.vscode
**/bin
**/obj

2. Environment Variables

ASP.NET Core reads environment variables automatically. You can inject configuration without changing code.

# docker-compose.yml snippet
environment:
  - ConnectionStrings__DefaultConnection=Server=db;Database=mydb;User Id=sa;Password=MyStrongPassword!;
  - Logging__LogLevel__Default=Warning

Note: Use double underscores (__) to represent nested JSON configuration sections.

Phase 3: Advanced Production Patterns

Now let's optimize for Security and Size.

1. Running as a Non-Root User

By default, Docker containers run as root. This is a security risk. Microsoft's .NET 8 images include a non-root user named app.

Update the "Runtime" stage of your Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=publish /app/publish .

# Switch to non-root user
USER app
ENTRYPOINT ["dotnet", "MyWebApi.dll"]

2. Chiseled Ubuntu Images

For the ultimate production optimization, use "Chiseled" images. These are stripped-down Ubuntu images with no shell, no package manager, and no root user capabilities. They are incredibly small and secure.

Change the base image in the final stage:

# Use the Chiseled image for runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyWebApi.dll"]

Note: You cannot use docker exec -it <container> bash with chiseled images because there is no bash!

Phase 4: Full Orchestration Example

Let's put it all together with a docker-compose.yml that sets up the API, SQL Server, and Redis.

version: '3.8'

services:
  api:
    build: 
      context: .
      dockerfile: MyWebApi/Dockerfile
    ports:
      - "8080:8080"
    environment:
      - ConnectionStrings__DefaultConnection=Server=sql;Database=MyDb;User Id=sa;Password=YourStrong!Password;TrustServerCertificate=True;
      - Redis__ConnectionString=redis
      - ASPNETCORE_URLS=http://+:8080
    depends_on:
      - sql
      - redis

  sql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_SA_PASSWORD=YourStrong!Password
    ports:
      - "1433:1433"
    volumes:
      - sql-data:/var/opt/mssql/data

  redis:
    image: redis:alpine

volumes:
  sql-data:

Summary

We've moved way past the default "Hello World" container. You now have a build pipeline that is cache-optimized, secure by default (non-root), and ultra-lightweight (chiseled).

Don't just copy-paste this into your project—understand why the layers are ordered this way. Your future self (and your CI/CD bill) will thank you.

Share this article:

Related Articles

FSI

Full Stack Insights

Software Engineer

Passionate about software development, architecture, and sharing knowledge with the community.