Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 075 / 100

Dockerizing a .NET app: a practical guide for developers

A small, secure image in a multi-stage Dockerfile.

Expona Admin7 min read

Dockerizing a .NET app

After 75 days of building and deploying .NET applications, you're likely familiar with the friction of "it works on my machine" syndrome. Containerization solves this by packaging your entire application—runtime, dependencies, and all—into a consistent image that runs identically everywhere. Dockerizing a .NET app properly means thinking strategically about image size, security, and build performance, which is exactly what we'll cover today.

Why Multi-Stage Builds Matter

When I first started containerizing .NET applications, I made the mistake of using a single stage: copy everything into the SDK image and ship it. The resulting image was enormous—often 3–4 GB—because it included the entire .NET SDK, build tools, NuGet caches, and source code. None of that belongs in production.

Multi-stage builds solve this elegantly. You use one stage (the builder) to compile and publish your application, then copy only the compiled output into a smaller runtime image. This is the standard pattern for .NET containerization today.

Here's a practical example. Suppose you're building an e-commerce API with Entity Framework Core and complex business logic:

csharp
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["EcommercePlatform.Api.csproj", "."]
RUN dotnet restore "EcommercePlatform.Api.csproj"
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "EcommercePlatform.Api.dll"]

The first stage pulls in the SDK (which includes compilers, debuggers, and the full toolchain), restores dependencies, and publishes the application. The second stage starts fresh with only the ASP.NET Core runtime—no SDK, no build tools. The final image typically runs 200–400 MB instead of several gigabytes.

Restore Before Copying Source: Docker Layer Caching

Docker builds images in layers. Each instruction creates a new layer, and Docker caches layers aggressively. If a layer hasn't changed, Docker reuses the cached version instead of rebuilding it. This is powerful, but only if you structure your Dockerfile correctly.

A common mistake is copying the entire source tree first, then restoring:

csharp
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .  # Wrong: includes everything
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish

Every time you change a single line of code, that COPY . . layer invalidates the restore layer. Docker rebuilds it from scratch, downloading all your NuGet packages again. On a slow connection or with large dependency trees, this wastes precious build time.

The correct approach is to copy only the project file first, restore dependencies, then copy the source:

csharp
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["EcommercePlatform.Api.csproj", "."]
RUN dotnet restore "EcommercePlatform.Api.csproj"
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "EcommercePlatform.Api.dll"]

Now when you modify your C# code, the restore layer stays cached. Docker only rebuilds the publish layer, which is much faster. On a project with 50+ dependencies, this can save minutes per build, especially in CI/CD pipelines.

Running as a Non-Root User

By default, Docker containers run as root. This is a security risk. If an attacker compromises your application and gains code execution inside the container, they immediately have root privileges inside the container filesystem. While Docker isolates the host from the container, running as non-root follows the principle of least privilege and reduces the attack surface.

The ASP.NET Core runtime images come with a built-in non-root user called app. Use it:

csharp
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["EcommercePlatform.Api.csproj", "."]
RUN dotnet restore "EcommercePlatform.Api.csproj"
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
RUN chown -R app:app /app
USER app
ENTRYPOINT ["dotnet", "EcommercePlatform.Api.dll"]

The chown command ensures the app user owns the files. The USER app directive switches to that user before running the container. If your application tries to write to a system directory or access privileged resources, it will fail loudly, which is exactly what you want—it forces you to fix the issue rather than accidentally relying on root privileges.

In production, I've seen exploits fail entirely because they assumed root access. Running as non-root isn't a silver bullet, but it's a simple, high-impact security measure.

Chiseled Images: Cutting Size and Attack Surface

Microsoft introduced chiseled images as an experimental feature in .NET 8. They're designed specifically for production workloads where image size and security matter. Instead of a traditional Linux distribution, chiseled images are built from scratch with only the minimal set of files needed to run .NET applications.

A standard ASP.NET Core runtime image is 200+ MB. A chiseled image can be 100 MB or less. More importantly, with fewer system packages and binaries, there are fewer potential vulnerabilities to patch.

Here's how to use chiseled images:

csharp
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["EcommercePlatform.Api.csproj", "."]
RUN dotnet restore "EcommercePlatform.Api.csproj"
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled
WORKDIR /app
COPY --from=build /app/publish .
RUN chown -R app:app /app
USER app
ENTRYPOINT ["dotnet", "EcommercePlatform.Api.dll"]

The only change is mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled instead of the standard runtime. Chiseled images work well for straightforward .NET applications. However, if your app requires shell access for debugging or relies on system utilities like curl or openssl, you may need the standard image.

At my organization, we've migrated our microservices to chiseled images and reduced deployment time significantly—fewer MB to push to registries means faster deploys. For a ride-hailing platform processing thousands of requests per second, every millisecond of startup time counts.

Common Mistakes

  • Copying source before restoring: This invalidates your restore cache on every code change, forcing unnecessary package downloads.
  • Using the SDK image in production: Shipping the full SDK wastes space and increases the attack surface. Always use the runtime image in the final stage.
  • Forgetting to change ownership: If you copy files in the final stage, they're owned by root. The non-root user can't write to them, causing runtime failures.
  • Not testing locally before deploying: Use docker build and docker run locally to verify your image works. CI/CD catches issues too late.
  • Ignoring .dockerignore: Without it, COPY . . includes node_modules, bin/, obj/, .git/, and test files. Add a .dockerignore file with common patterns to reduce context size and build time.

A Real-World Example

On a trading platform I worked on, we had strict latency requirements. Every millisecond of startup time affected user experience. Our original Dockerfile was naive: single stage, no caching strategy. Build times averaged 6–8 minutes in CI/CD.

After refactoring to use multi-stage builds, proper restore ordering, and chiseled images, build times dropped to 2–3 minutes. The image size went from 1.2 GB to 140 MB. In Kubernetes, this meant faster pod startups and lower network overhead when pulling images. Over a year, we recouped the refactoring time many times over.

csharp
# Real trading platform example
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["TradingPlatform.Api/TradingPlatform.Api.csproj", "TradingPlatform.Api/"]
COPY ["TradingPlatform.Core/TradingPlatform.Core.csproj", "TradingPlatform.Core/"]
RUN dotnet restore "TradingPlatform.Api/TradingPlatform.Api.csproj"
COPY . .
WORKDIR "/src/TradingPlatform.Api"
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled
WORKDIR /app
COPY --from=build /app/publish .
RUN chown -R app:app /app
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "TradingPlatform.Api.dll"]

Notice we also added EXPOSE 8080 to document the port. This doesn't actually publish the port—it's purely documentation—but it helps operators understand how to run the container.

Key Takeaways

  • Use multi-stage builds to separate compilation from runtime, drastically reducing final image size and security footprint.
  • Restore before copying source code to leverage Docker's layer caching and speed up iterative builds.
  • Always run containers as a non-root user. Use the app user and chown to follow the principle of least privilege.
  • Consider chiseled images for production deployments to cut image size, reduce startup time, and minimize the attack surface.
  • Test your Docker build locally before committing to CI/CD. A misconfigured Dockerfile will fail loudly in production.
  • Use .dockerignore to exclude unnecessary files and keep your build context lean, which speeds up the build process.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles