Ravindra BagaleCourses & study guides मराठी

Chapter 54: Dockerfile Examples for Node.js, Python Flask and Django, Spring Boot, .NET, Go, PHP, nginx, MySQL, Postgres and Redis

54.4 .NET 8 ASP.NET Core Dockerfile

Friends, .NET apps fit Docker very nicely because Microsoft gives separate images for building and for running. Today we write an ASP.NET Core 8 Dockerfile step by step: the SDK image to restore and publish, the small aspnet image to run, the built-in app user, port 8080, health checks and even a chiseled image with no shell.

1. Start from a story

At Infosys, a team maintains an ASP.NET Core API for an insurance client. It returns policy details. For years it ran on Windows servers in IIS.

The client wanted it on Linux containers in AWS. The first attempt copied the bin folder from a developer laptop into an image. It failed on Linux because the laptop built it in Debug mode with Windows paths.

Then they used the SDK image for everything. It worked, but the image was 850 MB and contained the full compiler and NuGet caches.

Meera, the tech lead, rewrote it: restore and publish in the SDK stage, run on the aspnet runtime as the app user on port 8080. The image fell to about 220 MB, and to about 110 MB with the chiseled variant. Today we follow her steps.

Stage buildmcr.microsoft.com/dotnet/sdk:8.0dotnet restoreonly .csproj copied firstdotnet publish -c Releaseoutput to /app/publishStage finaldotnet/aspnet:8.0USER app, port 8080non-root by defaultENTRYPOINT dotnet Policy.dllthe app is PID 1
Picture 1: .NET image pattern

2. Build and publish in the SDK stage

Step 1: The sample API

Why: Something to containerise.

What: Create a minimal API with the dotnet CLI inside a container, so you do not even need .NET installed.

Example 1: Create

Type this:

docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:8.0 dotnet new webapi -n Policy --no-https -o . && ls

You will see:

Policy.csproj  Program.cs  Properties  appsettings.json

What it means: A ready web API project.

Example 2: Add a route

Type this:

printf '%s\n' 'var builder = WebApplication.CreateBuilder(args);' 'builder.Services.AddHealthChecks();' 'var app = builder.Build();' 'app.MapGet("/policy/{id}", (int id) => new { id, plan = "Gold" });' 'app.MapHealthChecks("/healthz");' 'app.Run();' > Program.cs && echo ok

You will see:

ok

What it means: One route plus a health endpoint.

Example 3: Ignore build output

Type this:

printf '%s\n' 'bin/' 'obj/' '.vs/' '*.user' > .dockerignore && cat .dockerignore

You will see:

bin/
obj/
.vs/
*.user

What it means: Never copy laptop build output into the image.

Step 2: Restore first for caching

Why: NuGet downloads happen once.

What: Copy only the .csproj and run dotnet restore. Packages are cached in that layer until the project file changes.

Example 4: Stage start

Type this:

printf '%s\n' '# syntax=docker/dockerfile:1' 'FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build' 'WORKDIR /src' 'COPY Policy.csproj .' 'RUN dotnet restore' > Dockerfile && cat Dockerfile | tail -2

You will see:

COPY Policy.csproj .
RUN dotnet restore

What it means: Project file before code.

Example 5: Build stage

Type this:

docker build --target build -t policy:build . 2>&1 | grep -i restored

You will see:

Restored /src/Policy.csproj (in 2.31 sec).

What it means: Packages downloaded.

Example 6: Edit code and rebuild

Type this:

echo '// note' >> Program.cs && docker build --target build -t policy:build . 2>&1 | grep -c CACHED

You will see:

3

What it means: Restore was cached.

Step 3: Publish in Release mode

Why: The real build.

What: dotnet publish compiles in Release and puts everything the app needs into one folder. --no-restore uses the earlier layer.

Example 7: Add publish

Type this:

printf '%s\n' 'COPY . .' 'RUN dotnet publish -c Release -o /app/publish --no-restore /p:UseAppHost=false' >> Dockerfile && tail -1 Dockerfile

You will see:

RUN dotnet publish -c Release -o /app/publish --no-restore /p:UseAppHost=false

What it means: UseAppHost=false skips the native launcher; we start with dotnet.

Example 8: Look inside

Type this:

docker build -q --target build -t policy:build . && docker run --rm policy:build ls /app/publish | head -4

You will see:

Policy.dll
Policy.deps.json
Policy.runtimeconfig.json
appsettings.json

What it means: The DLL and its config files.

Example 9: Size of the SDK image

Type this:

docker images policy:build --format '{{.Size}}'

You will see:

861MB

What it means: Too big to ship. That is why we need a second stage.

Step 4: Cache the NuGet folder

Why: Faster CI builds.

What: A BuildKit cache mount keeps /root/.nuget/packages between builds without putting it into any layer.

Example 10: Use the mount

Type this:

sed -i 's#^RUN dotnet restore#RUN --mount=type=cache,target=/root/.nuget/packages dotnet restore#' Dockerfile && grep restore Dockerfile

You will see:

RUN --mount=type=cache,target=/root/.nuget/packages dotnet restore

What it means: Packages stay on the build machine.

Example 11: Publish uses it too

Type this:

sed -i 's#^RUN dotnet publish#RUN --mount=type=cache,target=/root/.nuget/packages dotnet publish#' Dockerfile && grep -c mount Dockerfile

You will see:

2

What it means: Both commands share the cache.

Example 12: Time it

Type this:

time docker build -q --target build -t policy:build . | tail -1

You will see:

real    0m6.902s

What it means: Rebuilds take seconds.

3. A small, safe runtime

sdk:8.0about 860 MB, build onlyaspnet:8.0about 220 MB, Debian runtimeaspnet:8.0-alpineabout 110 MB, muslaspnet:8.0-jammy-chiseledabout 110 MB, no shell, no package managerSelf-contained trimmedruntime-deps image, smallest
Picture 2: .NET runtime choices

Step 5: The aspnet runtime stage

Why: Only what runs the app.

What: The aspnet image has the ASP.NET Core runtime but no SDK. We copy the publish folder from the build stage.

Example 13: Final stage

Type this:

printf '%s\n' 'FROM mcr.microsoft.com/dotnet/aspnet:8.0' 'WORKDIR /app' 'COPY --from=build /app/publish .' 'ENTRYPOINT ["dotnet", "Policy.dll"]' >> Dockerfile && grep FROM Dockerfile

You will see:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
FROM mcr.microsoft.com/dotnet/aspnet:8.0

What it means: Two stages.

Example 14: Build and run

Type this:

docker build -q -t policy:1.0 . && docker run -d -p 8080:8080 --name p policy:1.0 && sleep 3 && curl -s localhost:8080/policy/7

You will see:

{"id":7,"plan":"Gold"}

What it means: The API answers.

Example 15: Size

Type this:

docker images policy:1.0 --format '{{.Size}}'

You will see:

221MB

What it means: From 861 MB to 221 MB.

Step 6: Port 8080 and ASPNETCORE_URLS

Why: The .NET 8 change.

What: From .NET 8 the images listen on 8080, not 80, so the app can run as non-root. ASPNETCORE_HTTP_PORTS changes it if needed.

Example 16: Check the default

Type this:

docker inspect policy:1.0 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PORT

You will see:

ASPNETCORE_HTTP_PORTS=8080

What it means: Set by the base image.

Example 17: Change the port

Type this:

docker run -d -e ASPNETCORE_HTTP_PORTS=5000 -p 5000:5000 --name p5 policy:1.0 && sleep 3 && curl -s localhost:5000/healthz

You will see:

Healthy

What it means: Now on 5000.

Example 18: Common mistake

Type this:

docker run -d -p 8090:80 --name pbad policy:1.0 && sleep 3 && curl -s -m 2 localhost:8090/healthz || echo 'connection reset'

You will see:

connection reset

What it means: Mapping to 80 fails; the app listens on 8080.

Step 7: The built-in app user

Why: Non-root in one line.

What: .NET 8 images include a user named app with UID 1654. Switch to it with USER.

Example 19: Before

Type this:

docker exec p id

You will see:

uid=0(root) gid=0(root) groups=0(root)

What it means: Still root by default.

Example 20: Switch

Type this:

sed -i 's#^ENTRYPOINT#USER app\nENTRYPOINT#' Dockerfile && grep -B1 ENTRYPOINT Dockerfile

You will see:

USER app
ENTRYPOINT ["dotnet", "Policy.dll"]

What it means: USER just before ENTRYPOINT.

Example 21: After

Type this:

docker build -q -t policy:1.1 . && docker rm -f p && docker run -d -p 8080:8080 --name p policy:1.1 && docker exec p id

You will see:

uid=1654(app) gid=1654(app) groups=1654(app)

What it means: Non-root, no extra RUN line needed.

Step 8: Health check endpoint

Why: Docker knows when the API is ready.

What: We already mapped /healthz. The aspnet image has no curl, so the simplest Docker HEALTHCHECK is in Compose with a tool you add, or we rely on the orchestrator probe.

Example 22: Probe by hand

Type this:

curl -s localhost:8080/healthz

You will see:

Healthy

What it means: The endpoint works.

Example 23: Compose healthcheck with curl added

Type this:

printf '%s\n' 'RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*' > curl.part && sed -i '/^WORKDIR \/app/r curl.part' Dockerfile && printf '%s\n' 'HEALTHCHECK --interval=15s CMD curl -fs localhost:8080/healthz || exit 1' > hc.part && sed -i '/^USER app/e cat hc.part' Dockerfile && grep HEALTH Dockerfile

You will see:

HEALTHCHECK --interval=15s CMD curl -fs localhost:8080/healthz || exit 1

What it means: Adds about 10 MB; fine for most teams.

Example 24: Status

Type this:

docker build -q -t policy:1.2 . && docker rm -f p && docker run -d -p 8080:8080 --name p policy:1.2 && sleep 20 && docker ps --format '{{.Names}} {{.Status}}' --filter name=^p$

You will see:

p Up 20 seconds (healthy)

What it means: Healthy.

Step 9: Settings with environment variables

Why: No secrets in appsettings.json.

What: ASP.NET Core reads environment variables. A double underscore means a nested key: ConnectionStrings__Db maps to ConnectionStrings:Db.

Example 25: Set env

Type this:

docker run -d -e ASPNETCORE_ENVIRONMENT=Production -e ConnectionStrings__Db='Host=db;Username=app;Password=example-only' --name penv policy:1.2 && docker exec penv printenv ASPNETCORE_ENVIRONMENT

You will see:

Production

What it means: Example password only; use secrets in real life.

Example 26: Secret file

Type this:

printf 'example-only-pass' > db_pass.txt && chmod 600 db_pass.txt && docker run -d -v "$PWD/db_pass.txt":/run/secrets/db_pass:ro --name psec policy:1.2 && docker exec psec cat /run/secrets/db_pass

You will see:

example-only-pass

What it means: Read the file in Program.cs at startup.

Example 27: Logs

Type this:

docker logs p 2>&1 | grep -m1 'Hosting environment'

You will see:

info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production

What it means: Production by default in the aspnet image.

Step 10: Chiseled images

Why: No shell, tiny attack surface.

What: Ubuntu chiseled images contain only the runtime files. There is no shell or apt, and they run as non-root already.

Example 28: Swap the base

Type this:

sed -i 's#dotnet/aspnet:8.0$#dotnet/aspnet:8.0-jammy-chiseled#' Dockerfile && sed -i '/apt-get/d;/^HEALTHCHECK/d' Dockerfile && grep FROM Dockerfile | tail -1

You will see:

FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled

What it means: Remove apt and curl lines; they cannot work here.

Example 29: Size

Type this:

docker build -q -t policy:chiseled . && docker images policy:chiseled --format '{{.Size}}'

You will see:

117MB

What it means: Half of the Debian runtime.

Example 30: No shell

Type this:

docker run -d --name pc policy:chiseled && docker exec pc sh -c 'echo hi' 2>&1 | head -1

You will see:

OCI runtime exec failed: exec: "sh": executable file not found in $PATH

What it means: Attackers cannot get a shell either. Debug with docker debug or a sidecar.

Step 11: Self-contained and trimmed

Why: The smallest .NET image.

What: Publish the app with its own runtime and trim unused code. Then the final stage only needs runtime-deps, which has the native libraries but no .NET.

Example 31: Publish self-contained

Type this:

printf '%s\n' 'RUN dotnet publish -c Release -o /app/sc -r linux-x64 --self-contained true /p:PublishTrimmed=true' > sc.part && sed -i '/-o \/app\/publish/r sc.part' Dockerfile && grep self-contained Dockerfile

You will see:

RUN dotnet publish -c Release -o /app/sc -r linux-x64 --self-contained true /p:PublishTrimmed=true

What it means: Trimming removes unused libraries.

Example 32: runtime-deps stage

Type this:

printf '%s\n' 'FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-jammy-chiseled AS tiny' 'WORKDIR /app' 'COPY --from=build /app/sc .' 'ENTRYPOINT ["./Policy"]' >> Dockerfile && docker build -q --target tiny -t policy:tiny . && docker images policy:tiny --format '{{.Size}}'

You will see:

61MB

What it means: The app carries its own runtime.

Example 33: Test it

Type this:

docker run -d -p 8082:8080 --name pt policy:tiny && sleep 2 && curl -s localhost:8082/policy/3

You will see:

{"id":3,"plan":"Gold"}

What it means: Test well; trimming can break reflection-heavy code.

Step 12: Run it with Postgres in Compose

Why: The real setup.

What: In Compose the API reaches Postgres by service name. The connection string comes from the environment, and the API waits for a healthy database.

Example 34: compose.yaml

Type this:

printf '%s\n' 'services:' '  db:' '    image: postgres:16' '    environment: {POSTGRES_PASSWORD: example-only}' '    healthcheck: {test: ["CMD","pg_isready"], interval: 5s}' '  api:' '    build: .' '    ports: ["8080:8080"]' '    environment:' '      ConnectionStrings__Db: Host=db;Username=postgres;Password=example-only' '    depends_on: {db: {condition: service_healthy}}' > compose.yaml && docker compose config -q && echo valid

You will see:

valid

What it means: Example password only.

Example 35: Up

Type this:

docker compose up -d --build --wait 2>&1 | tail -2

You will see:

✔ Container policy-db-1   Healthy
✔ Container policy-api-1  Started

What it means: The database first, then the API.

Example 36: Call it

Type this:

curl -s localhost:8080/policy/11

You will see:

{"id":11,"plan":"Gold"}

What it means: Working stack.

4. Common mistakes and how Meera avoids them

Mistake one: copying bin/Debug from a laptop. Meera publishes in Release inside the SDK stage.

Mistake two: shipping the SDK image. She runs on aspnet or chiseled.

Mistake three: mapping port 80 on .NET 8. She maps 8080.

Mistake four: running as root. She uses USER app.

Mistake five: secrets in appsettings.json. She passes them as environment variables or secret files.

Check yourself

Q1. Which image builds and which runs?

sdk:8.0 builds; aspnet:8.0 runs.

Q2. Which port does a .NET 8 container use by default?

8080.

Q3. What does ConnectionStrings__Db mean?

The nested setting ConnectionStrings:Db.

Q4. What is special about chiseled images?

No shell or package manager, non-root, about 110 MB.

Friends, restore first, publish in Release, run on aspnet, port 8080, USER app, settings from the environment, and chiseled when you want the smallest image. Did you understand? Let us try it once more. Build both the aspnet and the chiseled image and compare their sizes.

Ravindra Bagale's Tip

On .NET 8, if the container starts but you get connection reset, first check the port. Nine times out of ten you mapped 80 instead of 8080.

Lab: try at home

  1. Create the Policy API with the SDK container.
  2. Write the restore and publish stage.
  3. Add the aspnet runtime stage and run it.
  4. Switch to USER app and check id.
  5. Pass a connection string with a double underscore.
  6. Swap to the chiseled base and compare sizes.

Recap

  • SDK stage: restore first, then publish Release.
  • aspnet stage: copy the publish folder only.
  • Listen on 8080 in .NET 8.
  • USER app for non-root.
  • Environment variables for settings and secrets.
  • Chiseled images for the smallest runtime.

Ravi Sir's Cards for this lesson

मराठी cards