Dockerize an ASP.NET Core App (.NET 10)
So far you've run images other people built. Now you'll build your own: an ASP.NET Core application packed into an image called dotnet-day1:1.0, running in a container on port 8081. The introduction explained what a Dockerfile and image layers are with a tiny shell script; this lab applies them to a real .NET 10 web app, including the multi-stage build that every .NET team uses.
All outputs are from a real build with the .NET 10 SDK image (10.0.403) on Docker Engine 29.
Step 1: Create the application
With the .NET 10 SDK installed, create an empty web project:
dotnet new web -n DotnetDay1 -o dotnet-day1
cd dotnet-day1
No .NET SDK on your machine? You can even create the project with Docker: docker run --rm -v "${PWD}:/src" -w /src mcr.microsoft.com/dotnet/sdk:10.0 dotnet new web -n DotnetDay1 -o . (PowerShell). The SDK runs in a temporary container and writes the files into your current folder.
Replace Program.cs with two endpoints. The /info endpoint will prove that the app really runs inside a container:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello from ASP.NET Core running inside a Docker container!");
app.MapGet("/info", () => new
{
Machine = Environment.MachineName,
Framework = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
OS = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
Environment = app.Environment.EnvironmentName,
Time = DateTime.UtcNow
});
app.Run();
The project file was generated by the template:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Step 2: Write a multi-stage Dockerfile
Create a file named Dockerfile (no extension) in the project folder:
# Stage 1: build and publish the app with the full .NET SDK
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY DotnetDay1.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# Stage 2: run the app on the smaller ASP.NET Core runtime image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "DotnetDay1.dll"]
Why two stages?
Building a .NET app needs the SDK (compiler, NuGet, MSBuild). Running it needs only the ASP.NET Core runtime. A multi-stage Dockerfile uses the big SDK image to build, then copies only the published output into the small runtime image. The SDK stage is thrown away and never shipped.
| Instruction | Why it's there |
|---|---|
FROM ...sdk:10.0 AS build | Start stage 1 from the official SDK image and name the stage build. |
COPY DotnetDay1.csproj . then RUN dotnet restore | Restore NuGet packages before copying the code. The restore layer is cached and reused until the .csproj changes, so editing C# files doesn't download packages again. |
COPY . . and RUN dotnet publish | Copy the source and produce the release build in /app/publish. |
FROM ...aspnet:10.0 AS final | Start a fresh stage from the runtime-only image. |
COPY --from=build | Take only the published files from stage 1. |
EXPOSE 8080 | Document the port the app listens on (see below). |
ENTRYPOINT ["dotnet", "DotnetDay1.dll"] | The command that starts the app when a container starts. |
Add a .dockerignore
Docker sends the whole folder (the build context) to the daemon. Exclude what the image doesn't need, especially your local bin and obj folders, which can break the build inside Linux:
bin/
obj/
.vs/
*.user
Dockerfile
.dockerignore
Step 3: Build the image
docker build -t dotnet-day1:1.0 .
-t dotnet-day1:1.0 names (tags) the image: repository dotnet-day1, tag 1.0. The final . is the build context: the current folder.
The first build is slow (80 seconds here) only because the SDK image had to be downloaded. The actual restore and publish took about 10 seconds. Build the same thing again and every step is reused from the cache:
Step 4: Check the image
docker images
This is the payoff of the multi-stage build: our image is 342 MB, while the SDK image used to build it is 1.31 GB. Shipping the SDK would have made the image almost four times bigger, and full of tools an attacker could use.
docker history shows the layers of the final image. Our app added only one small layer on top of the runtime image:
docker history dotnet-day1:1.0
Our compiled application is just 147 kB. Everything below it is the shared ASP.NET Core runtime, which is downloaded once and reused by every .NET image on the machine.
Step 5: Run the container on port 8081
docker run -d -p 8081:8080 --name dotnet-day1 dotnet-day1:1.0
docker ps
Check the application's start-up messages:
docker logs dotnet-day1
Why port 8080 inside the container?
Since .NET 8, the official ASP.NET Core images set the environment variable ASPNETCORE_HTTP_PORTS=8080, so Kestrel listens on 8080 (not 80 as in older versions). This lets the app run as a non-root user, who isn't allowed to open ports below 1024. You can confirm it inside the container:
docker exec dotnet-day1 printenv ASPNETCORE_HTTP_PORTS DOTNET_RUNNING_IN_CONTAINER
So the mapping must be -p 8081:8080: host port 8081, container port 8080. Notice also http://[::]:8080 in the log: the app listens on all network interfaces, not only on localhost, which is required for port forwarding to work. (The FastAPI lab shows what happens when an app listens only on localhost.)
Step 6: Test in the browser
Open http://localhost:8081:
And http://localhost:8081/info:
- machine is the container ID, the container's hostname.
- os is Ubuntu 24.04, the base of the
aspnet:10.0image, even though the host is Windows. - environment is Production: containers run in Production unless you pass
-e ASPNETCORE_ENVIRONMENT=Development.
Common mistake: wrong container port
Using the old habit -p 8081:80, the container starts fine, but nothing listens on port 80 inside it:
docker run -d -p 8081:80 --name dotnet-wrong dotnet-day1:1.0
curl http://localhost:8081/
docker ps gives the hint: 8080/tcp, 0.0.0.0:8081->80/tcp. The image exposes 8080, but we forwarded to 80. Fix the mapping to 8081:8080, or change the app's port with -e ASPNETCORE_HTTP_PORTS=80 (only if the container runs as root).
Useful variations
# Run in Development mode
docker run -d -p 8081:8080 -e ASPNETCORE_ENVIRONMENT=Development --name dotnet-dev dotnet-day1:1.0
# Pass a configuration value (overrides appsettings.json: ConnectionStrings:Default)
docker run -d -p 8081:8080 -e ConnectionStrings__Default="Server=..." dotnet-day1:1.0
# Build a new version without touching 1.0
docker build -t dotnet-day1:1.1 .
Environment variables with __ (double underscore) map to nested configuration keys in ASP.NET Core, so the same image can run in every environment with different settings: one of the main problems Docker solves.
Interview questions
Why use a multi-stage Dockerfile for .NET?
To build with the SDK image but ship only the runtime image plus the published files. The final image is much smaller (342 MB instead of 1.31 GB here) and contains no build tools.
Why do .NET 8+ containers listen on port 8080?
The official images set ASPNETCORE_HTTP_PORTS=8080 so the app can run as a non-root user, which can't bind ports below 1024.
Why copy the .csproj and restore before copying the rest of the code?
For the build cache. The restore layer depends only on the project file, so it's reused when only source files change, and packages aren't restored on every build.
What is the difference between dotnet-day1 and dotnet-day1:1.0?
Without a tag Docker assumes :latest. Use explicit version tags so you know exactly which build is running and can roll back.
Summary
- A multi-stage Dockerfile builds with
sdk:10.0and runs onaspnet:10.0. docker build -t dotnet-day1:1.0 .builds and tags the image; restore is cached separately from the code.- ASP.NET Core containers listen on 8080, so run them with
-p 8081:8080. - The same image runs anywhere; change behaviour with environment variables, not by rebuilding.
Next lab: Create a Python FastAPI Docker image (python-fastapi:1.0) and run it on port 8002.