This content originally appeared on DEV Community and was authored by Cristian Sifuentes
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_UIDline? - 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"]
At a high level, this is a classic multi‑stage Dockerfile:
-
base→ ASP.NET runtime, non‑root user, ports exposed -
build→ full .NET SDK, restores & compiles your Web API -
publish→ takes the build output and publishes a trimmed app -
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
What this does:
- Uses ASP.NET Core 9.0 runtime (Linux container).
- Switches to a non‑root user via
USER $APP_UID. - Sets
/appas 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
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/...
- Flow:
- Copy minimal files (
.props+.csproj) for faster restore caching. -
dotnet restoredownloads all NuGet packages. - Copy the rest of the source (
COPY . .). - Build the Web API project into
/app/build.
- Copy minimal files (
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
What happens here:
- Reuses the build stage (SDK + source + dependencies).
- Runs
dotnet publishto generate an optimized output into/app/publish. - Uses
/p:UseAppHost=falseto avoid bundling a platform‑specific executable; you’ll run withdotnet 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"]
This is the stage that creates the image you actually run:
- Starts from the
baseruntime image (ASP.NET 9.0, non‑root user, ports exposed). - Copies the published output from the
publishstage. - Sets the entrypoint to:
dotnet Web.Api.dll
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
-
Docker Engine / Docker Desktop
- Windows, macOS, or Linux, with Linux containers enabled.
Correct project layout matching the Dockerfile
In the directory where you rundocker build, you should see something like:
Directory.Packages.props
Directory.Build.props
Web.Api/
Web.Api.csproj
Program.cs
appsettings.json
...
Dockerfile
- 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
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
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_UIDmust 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
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
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 .
✅ 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
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_UIDif 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 .
Want to be explicit about configuration?
docker build -t my-webapi --build-arg BUILD_CONFIGURATION=Release .
What Docker will do:
- Pull
mcr.microsoft.com/dotnet/sdk:10.0(or from cache) - Restore NuGet packages for
Web.Api.csproj - Build the project to
/app/build - Publish the project to
/app/publish - Create a final runtime image from
aspnet:9.0with/app/publishcopied in
If the build fails, check:
- Are
Directory.Packages.propsandDirectory.Build.propsreally in the context? - Is the project folder exactly
Web.Apiand the file exactlyWeb.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
What this means:
-
--rm→ removes the container when it stops -
-p 8080:8080→ host 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/swaggerdepending 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
- Host 8080 → Container 80
Tip:
Always confirm your Kestrel configuration (ASPNETCORE_URLS,appsettings.json, orProgram.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
Why align versions?
- Less surprise when APIs change between SDK versions
- Consistent behavior between build and runtime
- Easier upgrades: bump both to
10.0at 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
- [ ]
Dockerfileis at the root of the solution (where you intend to build) - [ ]
Directory.Packages.propsandDirectory.Build.propsexist in that directory - [ ] There is a
Web.Apifolder withWeb.Api.csprojand the API source files
Security/User
- [ ] Either:
- [ ]
USER $APP_UIDis temporarily commented out for dev, or - [ ] A valid non‑root user is created and
APP_UID/APP_GIDare configured
- [ ]
Build
- [ ]
docker build -t my-webapi .completes successfully - [ ] No
dotnet restoreerrors (NuGet sources reachable, correct TFMs, etc.)
Run
- [ ]
docker run -p 8080:8080 my-webapistarts 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.
This content originally appeared on DEV Community and was authored by Cristian Sifuentes
Cristian Sifuentes | Sciencx (2025-11-25T21:02:39+00:00) ASP.NET Core + Docker: Mastering Multi-Stage Builds for Web APIs. Retrieved from https://www.scien.cx/2025/11/25/asp-net-core-docker-mastering-multi-stage-builds-for-web-apis/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.
