quires a combination of multi-stage build patterns, careful base image selection, and application-level compilation flags.
1. Multi-Stage Build Architecture
Multi-stage builds separate the compilation environment from the runtime environment. The build stage requires the SDK, while the runtime stage only needs the ASP.NET Core runtime.
Dockerfile Structure:
# Build Stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApp.csproj", "./"]
RUN dotnet restore "./MyApp.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "MyApp.csproj" -c Release -o /app/build
# Publish Stage
FROM build AS publish
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Runtime Stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
Key Rationale:
- Layer Caching: Copying the
.csproj file and running dotnet restore before copying source code leverages Docker layer caching. Dependencies are cached unless the project file changes, drastically speeding up CI builds.
- Chiseled Base:
8.0-jammy-chiseled is the recommended base for production. It is built from Ubuntu 22.04 LTS but stripped of all non-essential packages.
UseAppHost=false: Ensures the output is a framework-dependent deployment, reducing the number of files and ensuring the container's runtime is used.
2. Trimming and Native AOT
For further optimization, apply trimming or Native AOT. Trimming removes unused IL code from the application and dependencies. Native AOT compiles the entire application to a native binary, eliminating the JIT compiler overhead.
Project File Configuration for Trimming:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>Link</TrimMode>
</PropertyGroup>
Project File Configuration for Native AOT:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
Architecture Decision:
Use Trimming for most applications to reduce size without sacrificing reflection capabilities. Reserve Native AOT for scenarios requiring minimal cold start times or where the application is compatible with AOT constraints (e.g., no dynamic code generation). Native AOT requires InvariantGlobalization or explicit ICU installation, which can complicate the Dockerfile.
3. Non-Root Execution and Security
Production containers must not run as root. Chiseled images include a non-root user by default.
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS final
USER $APP_UID
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
4. Health Checks
Implement liveness and readiness probes to improve orchestration reliability.
HEALTHCHECK CMD curl --fail http://localhost:8080/health || exit 1
Pitfall Guide
-
Leaking the SDK in Runtime:
- Mistake: Using
FROM mcr.microsoft.com/dotnet/sdk in the final stage.
- Impact: Image size balloons to ~800MB. Security vulnerabilities increase due to included build tools.
- Fix: Always use
aspnet or runtime images for the final stage.
-
Ignoring Globalization in Alpine:
- Mistake: Running culture-dependent code on Alpine without
libicu or InvariantGlobalization.
- Impact: Runtime crashes or incorrect date/number formatting. Alpine uses musl libc, which handles globalization differently.
- Fix: Use Chiseled Ubuntu for glibc compatibility, or set
<InvariantGlobalization>true</InvariantGlobalization> and test thoroughly.
-
Blind Trimming Breaking Reflection:
- Mistake: Enabling
<PublishTrimmed>true</PublishTrimmed> on apps using heavy reflection (e.g., Newtonsoft.Json, EF Core without source generators).
- Impact: Missing methods at runtime,
MissingMethodException.
- Fix: Use
TrimmerRootAssembly or DynamicDependency attributes to preserve required code. Prefer NativeAOT-compatible libraries.
-
Inefficient Layer Ordering:
- Mistake: Copying all source files before restoring packages.
- Impact: Every code change invalidates the restore layer, causing full dependency resolution in every build.
- Fix: Copy
.csproj, restore, then copy source.
-
Running as Root:
- Mistake: Default Docker behavior runs as root.
- Impact: Security risk. If the container is compromised, the attacker has root privileges.
- Fix: Use
USER $APP_UID provided by Microsoft images.
-
Over-Optimizing with Native AOT:
- Mistake: Forcing Native AOT on legacy apps with complex reflection or unsupported libraries.
- Impact: Build failures, increased development friction, marginal gains if cold start isn't the bottleneck.
- Fix: Evaluate AOT only when cold start metrics demand it. Use Trimming as the first step.
-
Missing .dockerignore:
- Mistake: Not excluding
bin, obj, .git, and local config files.
- Impact: Build context becomes large, slowing down Docker daemon communication and potentially leaking secrets.
- Fix: Maintain a robust
.dockerignore file.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Serverless / Auto-Scaling | Native AOT + Chiseled | Minimizes cold start latency and image pull time. | High reduction in compute costs. |
| Legacy App with Reflection | Multi-stage Chiseled + No Trimming | Maintains compatibility while reducing size vs Debian. | Moderate reduction in storage/egress. |
| Internal Microservice | Multi-stage Alpine | Good size reduction; musl compatibility usually acceptable for simple apps. | Low to Moderate reduction. |
| Strict Security Compliance | Multi-stage Chiseled + Non-Root | Minimal attack surface; no shell or package manager present. | Reduced vulnerability management overhead. |
Configuration Template
Dockerfile:
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy project file for caching
COPY ["MyApp.csproj", "./"]
RUN dotnet restore "./MyApp.csproj"
# Copy source and build
COPY . .
RUN dotnet build "MyApp.csproj" -c $BUILD_CONFIGURATION -o /app/build
# Stage 2: Publish
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "MyApp.csproj" \
-c $BUILD_CONFIGURATION \
-o /app/publish \
/p:UseAppHost=false \
/p:PublishTrimmed=true \
/p:TrimMode=Link
# Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS final
WORKDIR /app
# Copy published output
COPY --from=publish /app/publish .
# Run as non-root user
USER $APP_UID
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "MyApp.dll"]
.dockerignore:
## .NET
bin/
obj/
*.user
*.suo
*.cache
*.dll
## IDE
.vs/
.vscode/
*.swp
*.swo
## Git
.git/
.gitignore
## Docker
Dockerfile
.dockerignore
docker-compose*.yml
## Misc
README.md
LICENSE
*.md
Quick Start Guide
- Update Dockerfile: Replace your existing Dockerfile with the Configuration Template provided above. Ensure the project name matches your assembly.
- Add
.dockerignore: Create a .dockerignore file in your project root with the content from the template to reduce build context size.
- Build and Verify: Run
docker build -t myapp:optimized . and check the image size using docker images. Expect a size under 70MB.
- Test Cold Start: Run the container and measure startup time using
docker run --rm -it myapp:optimized. Compare against your previous image to validate latency improvements.
- Scan for Vulnerabilities: Use
docker scan myapp:optimized or a tool like Trivy to confirm the reduced security surface of the Chiseled image.