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.

Top comments (0)