DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

ASP.NET Core + Docker: Mastering Multi-Stage Builds for Web APIs

ASP.NET Core + Docker: Mastering Multi‑Stage Builds for Web APIs

ASP.NET Core + Docker: Mastering Multi‑Stage Builds for Web APIs

“Visual Studio gave me this Dockerfile… but what is it actually doing?”

If you’ve ever right‑clicked “Add > Docker Support” in an ASP.NET project, you’ve probably seen a fairly complex multi‑stage Dockerfile appear in your repo.

It looks smart. It builds. It even runs in Debug.

But:

  • What does each stage really do?
  • What do you actually need installed to make it work?
  • Why is there a mysterious USER $APP_UID line?
  • How do you safely build and run the final image in your own environment?

This guide takes a real Dockerfile for an ASP.NET Core Web API and turns it into a clear mental model you can reuse in any .NET + Docker project.


TL;DR — What You’ll Learn

✅ How a multi‑stage Dockerfile for ASP.NET Core is structured (base → build → publish → final)

✅ What the USER $APP_UID line does and how to avoid permission problems

✅ What you actually need installed to build and run this image

✅ How to build and run the container step by step

✅ Why aligning aspnet:9.0 and sdk:10.0 versions matters

✅ A checklist to verify “yes, I can run this Dockerfile in my environment”

Copy‑paste friendly commands included. Let’s dissect this thing. 🪓


1. The Dockerfile We’re Analyzing (Big Picture)

Here’s the multi‑stage Dockerfile, slightly formatted:

# Base runtime stage (used for running the app)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081

# Build stage (used to compile the project)
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Web.Api/Web.Api.csproj", "Web.Api/"]
RUN dotnet restore "./Web.Api/Web.Api.csproj"
COPY . .
WORKDIR "/src/Web.Api"
RUN dotnet build "./Web.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build

# Publish stage (produces the final published output)
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Web.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

# Final runtime stage (what actually runs in prod)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Web.Api.dll"]
Enter fullscreen mode Exit fullscreen mode

At a high level, this is a classic multi‑stage Dockerfile:

  1. base → ASP.NET runtime, non‑root user, ports exposed
  2. build → full .NET SDK, restores & compiles your Web API
  3. publish → takes the build output and publishes a trimmed app
  4. final → runtime image + published app + clean entrypoint

Let’s go stage by stage.


2. Stage‑by‑Stage Breakdown

2.1 Base Stage — Runtime Image

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Uses ASP.NET Core 9.0 runtime (Linux container).
  • Switches to a non‑root user via USER $APP_UID.
  • Sets /app as the working directory.
  • Exposes ports 8080 and 8081 (for HTTP/HTTPS or multiple endpoints).

Mental model:

This is the “slim, production‑ready base” where your app will run. No SDK, just runtime + your files.


2.2 Build Stage — SDK Image for Compiling

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Web.Api/Web.Api.csproj", "Web.Api/"]
RUN dotnet restore "./Web.Api/Web.Api.csproj"
COPY . .
WORKDIR "/src/Web.Api"
RUN dotnet build "./Web.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Uses .NET SDK 10.0 (preview/future version) to restore and build the project.
  • Assumes these files exist in the build context (folder where you call docker build):
  Directory.Packages.props
  Directory.Build.props
  Web.Api/Web.Api.csproj
  Web.Api/...
Enter fullscreen mode Exit fullscreen mode
  • Flow:
    1. Copy minimal files (.props + .csproj) for faster restore caching.
    2. dotnet restore downloads all NuGet packages.
    3. Copy the rest of the source (COPY . .).
    4. Build the Web API project into /app/build.

Mental model:

This stage is your “build server inside a container”. It contains the full SDK and compiles your code, but it won’t be shipped as‑is to production.


2.3 Publish Stage — Final Output

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Web.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
Enter fullscreen mode Exit fullscreen mode

What happens here:

  • Reuses the build stage (SDK + source + dependencies).
  • Runs dotnet publish to generate an optimized output into /app/publish.
  • Uses /p:UseAppHost=false to avoid bundling a platform‑specific executable; you’ll run with dotnet Web.Api.dll.

Mental model:

This stage transforms your compiled app into the final published bundle that will be copied into the runtime image.


2.4 Final Stage — What Actually Runs in Production

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Web.Api.dll"]
Enter fullscreen mode Exit fullscreen mode

This is the stage that creates the image you actually run:

  • Starts from the base runtime image (ASP.NET 9.0, non‑root user, ports exposed).
  • Copies the published output from the publish stage.
  • Sets the entrypoint to:
  dotnet Web.Api.dll
Enter fullscreen mode Exit fullscreen mode

Mental model:

Final image = runtime + published app. Small, clean, production‑oriented.


3. What You Must Have Installed to Build This Image

The good news: you don’t need .NET SDK installed on your host to build this image. The Dockerfile uses SDK images inside the container.

Mandatory

  1. Docker Engine / Docker Desktop

    • Windows, macOS, or Linux, with Linux containers enabled.
  2. Correct project layout matching the Dockerfile

    In the directory where you run docker build, you should see something like:

   Directory.Packages.props
   Directory.Build.props
   Web.Api/
       Web.Api.csproj
       Program.cs
       appsettings.json
       ...
   Dockerfile
Enter fullscreen mode Exit fullscreen mode
  1. Internet access (at least for the first build) Docker must be able to pull:
  • mcr.microsoft.com/dotnet/aspnet:9.0
  • mcr.microsoft.com/dotnet/sdk:10.0 (or 9.0 if you align versions)
  • All NuGet packages during dotnet restore.

Optional (Nice to Have)

  • .NET SDK on your host Only needed if you also want to run:
  dotnet run
  dotnet test
Enter fullscreen mode Exit fullscreen mode

directly on your machine. The Docker build itself doesn’t require it.


4. The USER $APP_UID Trap (And How to Fix It)

This line lives in the base stage:

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
Enter fullscreen mode Exit fullscreen mode

It tries to ensure your app does not run as root inside the container. Great for security.

But there’s a catch:

For this to work correctly:

  • The environment variable APP_UID must be set at build or runtime, and
  • That UID must map to a valid user inside the container.

If not, you can get:

  • Permission errors
  • “No such user” problems
  • Confusing runtime failures

✅ Option 1 — Easiest for Local Dev: Comment it Out

For local testing only, you can temporarily remove or comment the line:

# USER $APP_UID
Enter fullscreen mode Exit fullscreen mode

Your app will run as root inside the container, which is fine for dev, but not ideal for production security.

✅ Option 2 — Define and Create a Non‑Root User

Hardened, production‑friendly version:

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base

ARG APP_UID=1000
ARG APP_GID=1000

RUN groupadd -g $APP_GID appgroup     && useradd -u $APP_UID -g $APP_GID -m appuser

USER appuser
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
Enter fullscreen mode Exit fullscreen mode

Now you can optionally override the UID/GID at build time:

docker build -t my-webapi --build-arg APP_UID=1001 --build-arg APP_GID=1001 .
Enter fullscreen mode Exit fullscreen mode

✅ Option 3 — Use a Predefined Non‑Root User (If Image Provides It)

Some images define built‑in users like dotnet or app. In that case, you might see:

USER app
Enter fullscreen mode Exit fullscreen mode

But that depends on the specific tag and image; check the official docs for the image you’re using.

Recommended strategy:

  • Dev: comment USER $APP_UID if it’s blocking you.
  • Prod: properly define and create the non‑root user as shown above.

5. How to Build the Image (Step by Step)

In the folder where your Dockerfile and Web.Api project live, run:

# Basic build (uses default Release configuration)
docker build -t my-webapi .
Enter fullscreen mode Exit fullscreen mode

Want to be explicit about configuration?

docker build -t my-webapi --build-arg BUILD_CONFIGURATION=Release .
Enter fullscreen mode Exit fullscreen mode

What Docker will do:

  1. Pull mcr.microsoft.com/dotnet/sdk:10.0 (or from cache)
  2. Restore NuGet packages for Web.Api.csproj
  3. Build the project to /app/build
  4. Publish the project to /app/publish
  5. Create a final runtime image from aspnet:9.0 with /app/publish copied in

If the build fails, check:

  • Are Directory.Packages.props and Directory.Build.props really in the context?
  • Is the project folder exactly Web.Api and the file exactly Web.Api.csproj?
  • Do you need to fix or remove USER $APP_UID?

6. How to Run the Container

Once the image builds successfully:

docker run --rm -p 8080:8080 --name my-webapi my-webapi
Enter fullscreen mode Exit fullscreen mode

What this means:

  • --rm → removes the container when it stops
  • -p 8080:8080host port 8080 → container port 8080
  • --name my-webapi → gives the container a readable name
  • my-webapi → the image you built

Now browse to:

  • http://localhost:8080
  • Maybe http://localhost:8080/swagger depending on your API setup.

What if your app listens on port 80 inside the container?

Sometimes ASP.NET is configured to listen on http://+:80 inside the container. In that case, change the mapping:

docker run --rm -p 8080:80 my-webapi
Enter fullscreen mode Exit fullscreen mode
  • Host 8080 → Container 80

Tip:

Always confirm your Kestrel configuration (ASPNETCORE_URLS, appsettings.json, or Program.cs) to map ports correctly.


7. Version Alignment: aspnet:9.0 vs sdk:10.0

Right now the Dockerfile uses:

  • Runtime: mcr.microsoft.com/dotnet/aspnet:9.0
  • SDK: mcr.microsoft.com/dotnet/sdk:10.0

This can work (SDK 10 building a .NET 9 app), but in most real‑world setups you want matching major versions, e.g.:

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
Enter fullscreen mode Exit fullscreen mode

Why align versions?

  • Less surprise when APIs change between SDK versions
  • Consistent behavior between build and runtime
  • Easier upgrades: bump both to 10.0 at once later

Rule of thumb:

Unless you explicitly know why you need a newer SDK, keep runtime and SDK on the same major version.


8. Quick Checklist: “Can I Run This Dockerfile?”

Use this as a quick validation list before you start debugging in circles:

Environment

  • [ ] Docker Desktop / Engine is installed and running
  • [ ] You are using Linux containers, not Windows containers

Project Layout

  • [ ] Dockerfile is at the root of the solution (where you intend to build)
  • [ ] Directory.Packages.props and Directory.Build.props exist in that directory
  • [ ] There is a Web.Api folder with Web.Api.csproj and the API source files

Security/User

  • [ ] Either:
    • [ ] USER $APP_UID is temporarily commented out for dev, or
    • [ ] A valid non‑root user is created and APP_UID/APP_GID are configured

Build

  • [ ] docker build -t my-webapi . completes successfully
  • [ ] No dotnet restore errors (NuGet sources reachable, correct TFMs, etc.)

Run

  • [ ] docker run -p 8080:8080 my-webapi starts the container
  • [ ] The app responds at http://localhost:8080 (or mapped port/route)
  • [ ] Logs show the app listening on the expected URL/port

If all items are checked, you’re in a solid place to start iterating and hardening.


Final Thoughts

Visual Studio’s generated Dockerfile isn’t magic — it’s a clean example of a multi‑stage build:

  • base → runtime foundation
  • build → SDK and compilation
  • publish → final app output
  • final → minimal runtime image

Once you fully understand a Dockerfile like this, you can:

  • Tweak it for different projects (other Web APIs, gRPC, background workers)
  • Enforce non‑root users correctly in production
  • Align SDK/runtime versions with intent
  • Plug the same image into Kubernetes, Azure Container Apps, ECS, Cloud Run, etc.

If you’d like a follow‑up article, here are some natural next steps:

  • Multi‑stage builds with Node + .NET (SPA + API in one image)
  • Using multi‑arch images for ARM (Apple Silicon, Raspberry Pi)
  • Dockerfile patterns for minimal images (Alpine, distroless)
  • Integrating this image into CI/CD pipelines (GitHub Actions, Azure DevOps, GitLab CI)

✍️ Written for engineers who don’t just want Docker to “work”, but want to understand what’s happening in every layer.

Top comments (0)