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.
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 /app/publish .
ENTRYPOINT ["dotnet", "MyWebApi.dll"]
Why this works:
- Cache Efficiency: We copy the
.csprojand rundotnet restorebefore copying the rest of the code. Docker caches this layer. If you change a.csfile but not your dependencies, the restore step is skipped. - 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 /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 /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.
Related Articles
Docker Mastery Part 5: Advanced Networking, Volumes, and Best Practices
Level up your Docker skills. Deep dive into Volumes for data persistence, advanced Networking types, and Dockerfile optimization.
Docker Mastery Part 4: Real-World Scenarios and Microservices
Apply your knowledge to real-world scenarios. We'll setup a full-stack Web App with a Database and explore Microservices architecture.
Full Stack Insights
Software Engineer
Passionate about software development, architecture, and sharing knowledge.
Quick Links
Full Stack Insights
Software Engineer
Passionate about software development, architecture, and sharing knowledge with the community.