Ravindra BagaleCourses & study guides मराठी

Chapter 57: Docker Projects Part 1: Websites, Databases, LAMP, LEMP, WordPress, 3-Tier and Every Language with MySQL and MongoDB

57.9 Project 9: .NET 8 minimal API with MySQL and MongoDB

Why: the problem

Suppose we are at ICICI Lombard and the claims team writes a small claims API in C# with .NET 8, like many banks and insurance companies in India. Developers on Windows have Visual Studio, but the test server is Ubuntu, and the API often works on one and fails on the other. With Docker we build with the official mcr.microsoft.com/dotnet/sdk:8.0 image and run on the much smaller aspnet:8.0 runtime image. .NET 8 images already have a non-root user called app and listen on port 8080 by default, so the Dockerfile stays short. We connect to MySQL with Entity Framework Core (Pomelo provider) and to MongoDB with the official MongoDB C# driver, chosen by one environment variable. Then we add a health check, read the logs, and make a chiseled (shell-less) Ubuntu image of about 120 MB.

What: every term in plain English

  • .NET SDK image — mcr.microsoft.com/dotnet/sdk:8.0. Has the compiler and dotnet publish. About 850 MB.
  • ASP.NET runtime image — mcr.microsoft.com/dotnet/aspnet:8.0. Only runs apps. About 220 MB.
  • Chiseled image — aspnet:8.0-jammy-chiseled. Ubuntu with no shell and no package manager. About 110 MB.
  • Minimal API — A .NET style where routes are written in Program.cs with app.MapGet and app.MapPost.
  • EF Core — Entity Framework Core, the .NET library that maps C# classes to SQL tables.
  • Pomelo — The popular EF Core provider for MySQL.
  • ASPNETCORE_HTTP_PORTS — Environment variable for the port the app listens on. Default 8080 in .NET 8.
  • APP_UID — The id (1654) of the non-root app user in .NET 8 images.
Create the project files. Write a multi-stage Dockerfile. Run with MySQL. Run with MongoDB. Add health checks. Try the chiseled image. Clean up.
.NET claims API: SDK build to aspnet or chiseled, then MySQL or MongoDB

How: step by step

  1. Create the project files. claims.csproj and Program.cs.
  2. Write a multi-stage Dockerfile. sdk build stage, aspnet runtime.
  3. Run with MySQL. EF Core creates the table.
  4. Run with MongoDB. Same image, DB_KIND=mongo.
  5. Add health checks. /healthz and a Compose test.
  6. Try the chiseled image. Smaller, no shell.
  7. Clean up. down -v.

Architecture

dotnet/sdk:8.0 build stage (publish to /out) dotnet-claims:1.0 on aspnet:8.0 (user app 1654, port 8080) EF Core + Pomelo to MySQL 8.4 or MongoDB.Driver to MongoDB 7 chiseled variant 118 MB (no shell) Named volume per database
.NET claims API architecture

Folder layout

dotnet-claims/
  claims.csproj
  Program.cs
  Dockerfile
  Dockerfile.chiseled
  .dockerignore
  .env
  compose.mysql.yaml
  compose.mongo.yaml

Example 1: The project file and the minimal API

Goal. The project file and the minimal API.

claims.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
    <PackageReference Include="MongoDB.Driver" Version="2.30.0" />
  </ItemGroup>
</Project>

Program.cs

using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;

var builder = WebApplication.CreateBuilder(args);
var kind = builder.Configuration["DB_KIND"] ?? "mysql";

if (kind == "mongo")
{
    var client = new MongoClient(builder.Configuration["MONGO_URL"]);
    builder.Services.AddSingleton(client.GetDatabase("claims").GetCollection<Claim>("claims"));
}
else
{
    var cs = builder.Configuration.GetConnectionString("Claims");
    builder.Services.AddDbContext<ClaimsDb>(o => o.UseMySql(cs, new MySqlServerVersion(new Version(8, 4))));
}
builder.Services.AddHealthChecks();

var app = builder.Build();
app.MapHealthChecks("/healthz");

if (kind == "mongo")
{
    app.MapGet("/claims", (IMongoCollection<Claim> c) => c.Find(_ => true).ToList());
    app.MapPost("/claims", (IMongoCollection<Claim> c, Claim claim) =>
    {
        c.InsertOne(claim);
        return Results.Created("/claims", claim);
    });
}
else
{
    using (var scope = app.Services.CreateScope())
    {
        var db = scope.ServiceProvider.GetRequiredService<ClaimsDb>();
        for (var i = 0; i != 15; i++)
        {
            try { db.Database.EnsureCreated(); break; }
            catch { Thread.Sleep(2000); }
        }
    }
    app.MapGet("/claims", (ClaimsDb db) => db.Claims.ToList());
    app.MapPost("/claims", (ClaimsDb db, Claim claim) =>
    {
        if (string.IsNullOrWhiteSpace(claim.Policy)) return Results.BadRequest("policy is required");
        db.Claims.Add(claim);
        db.SaveChanges();
        return Results.Created($"/claims/{claim.Id}", claim);
    });
}

app.Logger.LogInformation("claims api ready, db={Kind}", kind);
app.Run();

public class Claim
{
    [MongoDB.Bson.Serialization.Attributes.BsonIgnore]
    public int Id { get; set; }
    public string Policy { get; set; } = "";
    public decimal Amount { get; set; }
}

public class ClaimsDb(DbContextOptions<ClaimsDb> o) : DbContext(o)
{
    public DbSet<Claim> Claims => Set<Claim>();
}

What each line does

  • Sdk="Microsoft.NET.Sdk.Web" — A web project. The XML angle brackets show normally because this is inside a code block.
  • InvariantGlobalization — Removes the need for the ICU library, so chiseled images work.
  • GetCollection<Claim> — C# generics: a Mongo collection that holds Claim objects.
  • AddDbContext<ClaimsDb> — Registers EF Core with the MySQL connection string.
  • EnsureCreated in a retry loop — Creates the table, retrying for 30 seconds while MySQL starts.
  • MapHealthChecks — Adds /healthz that returns Healthy.
  • GetConnectionString("Claims") — Reads ConnectionStrings__Claims from the environment.

Green output

$ docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:8.0 dotnet restore
  Determining projects to restore...
  Restored /src/claims.csproj (in 4.1 sec).

Expected result. The project restores in a throwaway SDK container. No .NET is needed on the laptop.

Example 2: A multi-stage Dockerfile

Goal. A multi-stage Dockerfile.

Dockerfile

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY claims.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /out --no-restore /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /out ./
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "claims.dll"]

.dockerignore

bin/
obj/
.env
.vs/

What each line does

  • COPY claims.csproj first — NuGet packages are restored in their own cached layer.
  • dotnet publish -c Release — Builds an optimised app into /out.
  • UseAppHost=false — Skips the native launcher file; we start with dotnet claims.dll.
  • USER $APP_UID — Uses the built-in non-root user (id 1654).
  • bin/ obj/ in .dockerignore — Windows build output never goes into the Linux image.
  • No HEALTHCHECK here — aspnet:8.0 has no curl. We test /healthz from outside with curl, and Compose uses a check that needs no extra tools.

Green output

$ docker build -t dotnet-claims:1.0 .
 => [build 6/6] RUN dotnet publish -c Release -o /out --no-restore
 => [runtime 3/3] COPY --from=build /out ./
 => naming to docker.io/library/dotnet-claims:1.0
$ docker image ls dotnet-claims
REPOSITORY      TAG   SIZE
dotnet-claims   1.0   231MB
$ docker run --rm --entrypoint id dotnet-claims:1.0
uid=1654(app) gid=1654(app) groups=1654(app)

Expected result. A 231 MB image that runs as the app user.

Example 3: Run with MySQL and with MongoDB

Goal. Run with MySQL and with MongoDB.

compose.mysql.yaml

services:
  api:
    build: .
    image: dotnet-claims:1.0
    ports: ["8080:8080"]
    environment:
      DB_KIND: mysql
      ConnectionStrings__Claims: Server=db;Database=claims;User=claims;Password=${DB_PASSWORD}
    healthcheck:
      test: ["CMD", "dotnet", "--list-runtimes"]  # process check, no curl needed
      interval: 30s
    depends_on:
      db: { condition: service_healthy }
  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: ${ROOT_PASSWORD}
      MYSQL_DATABASE: claims
      MYSQL_USER: claims
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes: [db-data:/var/lib/mysql]
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${ROOT_PASSWORD}"]
      interval: 5s
      retries: 20
volumes:
  db-data:

compose.mongo.yaml

services:
  api:
    image: dotnet-claims:1.0
    ports: ["8080:8080"]
    environment:
      DB_KIND: mongo
      MONGO_URL: mongodb://root:${MONGO_PASSWORD}@mongo:27017/?authSource=admin
    depends_on:
      mongo: { condition: service_healthy }
  mongo:
    image: mongo:7
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
    volumes: [mongo-data:/data/db]
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 5s
      retries: 20
volumes:
  mongo-data:

.env

ROOT_PASSWORD=change-me-root
DB_PASSWORD=change-me-app
MONGO_PASSWORD=change-me-mongo

What each line does

  • ConnectionStrings__Claims — Double underscore means a nested key: ConnectionStrings:Claims in .NET config.
  • healthcheck override — Compose replaces the image HEALTHCHECK. Here we use a check that needs no curl.
  • DB_KIND: mongo — Program.cs registers the Mongo collection instead of EF Core.
  • image only in the mongo file — Reuses the image built by the MySQL file.

Green output

$ docker compose -f compose.mysql.yaml -p claims-mysql up -d --wait
 Container claims-mysql-db-1   Healthy
 Container claims-mysql-api-1  Healthy
$ curl -s -X POST localhost:8080/claims -H 'Content-Type: application/json' -d '{"policy":"CAR-2291","amount":18500}'
{"id":1,"policy":"CAR-2291","amount":18500}
$ curl -s localhost:8080/healthz
Healthy
$ docker compose -p claims-mysql logs api | grep ready
api-1  | info: Program[0] claims api ready, db=mysql

Expected result. The API saves claims in MySQL. The Mongo file runs the same image with DB_KIND=mongo.

Example 4: A chiseled image: smaller and with no shell

Goal. A chiseled image: smaller and with no shell.

Dockerfile.chiseled

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY claims.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /out --no-restore /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled
WORKDIR /app
COPY --from=build /out ./
EXPOSE 8080
ENTRYPOINT ["dotnet", "claims.dll"]

What each line does

  • jammy-chiseled — Ubuntu 22.04 cut down to only what .NET needs. Runs as non-root by default.
  • No USER line — Chiseled images already default to the app user.
  • No HEALTHCHECK — There is no shell or curl. Check health from outside or with a sidecar.
  • -f Dockerfile.chiseled — Builds from this file instead of the default Dockerfile.

Green output

$ docker build -f Dockerfile.chiseled -t dotnet-claims:chiseled .
$ docker image ls dotnet-claims
REPOSITORY      TAG        SIZE
dotnet-claims   chiseled   118MB
dotnet-claims   1.0        231MB
$ docker run --rm --entrypoint sh dotnet-claims:chiseled
docker: Error response from daemon: ... exec: "sh": executable file not found in $PATH

Expected result. The chiseled image is about half the size and has no shell.

Every command, step by step (plain docker and Compose)

  1. Make the folder

    mkdir dotnet-claims && cd dotnet-claims
    

    What it does: Creates the project folder. Add every file from the examples.

    (no output)
    
  2. Restore packages

    docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:8.0 dotnet restore
    

    What it does: Checks the csproj and downloads NuGet packages.

    Restored /src/claims.csproj
    
  3. Build

    docker build -t dotnet-claims:1.0 .
    

    What it does: SDK stage publishes, runtime stage runs.

    naming to docker.io/library/dotnet-claims:1.0
    
  4. Check user and port

    docker image inspect dotnet-claims:1.0 --format '{{.Config.User}} {{.Config.ExposedPorts}}'
    

    What it does: Shows the non-root user and port 8080.

    1654 map[8080/tcp:{}]
    
  5. Start MySQL stack

    docker compose -f compose.mysql.yaml -p claims-mysql up -d --wait
    

    What it does: MySQL, then the API.

    Container claims-mysql-api-1 Healthy
    
  6. Add a claim

    curl -s -X POST localhost:8080/claims -H 'Content-Type: application/json' -d '{"policy":"CAR-2291","amount":18500}'
    

    What it does: EF Core inserts a row.

    {"id":1,"policy":"CAR-2291","amount":18500}
    
  7. Look at the table

    docker compose -p claims-mysql exec db mysql -uclaims -pchange-me-app claims -e 'SELECT * FROM Claims'
    

    What it does: EnsureCreated made the Claims table.

    Id  Policy    Amount
    1   CAR-2291  18500.00
    
  8. Read logs

    docker compose -p claims-mysql logs api --tail 5
    

    What it does: Shows .NET logs.

    claims api ready, db=mysql
    
  9. Switch to Mongo

    docker compose -p claims-mysql down
    docker compose -f compose.mongo.yaml -p claims-mongo up -d --wait
    

    What it does: Same image, Mongo driver.

    Container claims-mongo-api-1 Started
    
  10. Build chiseled

    docker build -f Dockerfile.chiseled -t dotnet-claims:chiseled .
    

    What it does: Makes the smaller image.

    dotnet-claims   chiseled   118MB
    
  11. Clean up

    docker compose -f compose.mongo.yaml -p claims-mongo down -v
    docker compose -f compose.mysql.yaml -p claims-mysql down -v
    docker image rm dotnet-claims:1.0 dotnet-claims:chiseled
    

    What it does: Removes everything.

    Untagged: dotnet-claims:1.0
    

When it is red 1

Unable to connect to any of the specified MySQL hosts.

Cause. MySQL is not ready or the Server name is not db.

Fix, step by step

  1. Check Server=db in the connection string.
  2. Use depends_on with service_healthy.
  3. Keep the EnsureCreated retry loop.

When it is red 2

Process terminated. Couldn't find a valid ICU package installed on the system.

Cause. Chiseled or alpine image without ICU, and globalization is not invariant.

Fix, step by step

  1. Add InvariantGlobalization true to the csproj.
  2. Or use the -extra chiseled image that includes ICU.

When it is red 3

Failed to bind to address http://[::]:80: permission denied

Cause. Old settings force port 80, which a non-root user cannot open.

Fix, step by step

  1. Remove ASPNETCORE_URLS=http://+:80.
  2. Use the .NET 8 default port 8080, or set ASPNETCORE_HTTP_PORTS=8080.

When it is red 4

error NETSDK1004: Assets file 'obj/project.assets.json' not found

Cause. --no-restore was used but restore did not run in the image, or obj/ from Windows was copied.

Fix, step by step

  1. Make sure RUN dotnet restore comes first.
  2. Add bin/ and obj/ to .dockerignore.

When it is red 5

MongoAuthenticationException: Unable to authenticate using sasl protocol mechanism SCRAM-SHA-256

Cause. authSource=admin is missing or the password changed after the first start.

Fix, step by step

  1. Add ?authSource=admin.
  2. Practice data only: down -v and start again.

Practice tasks

Easy Task 1: Build

Why: Start.

What to do: Build dotnet-claims:1.0 and check the user.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker build -t dotnet-claims:1.0 .
docker run --rm --entrypoint id dotnet-claims:1.0

Easy Task 2: MySQL

Why: Basics.

What to do: Start the MySQL stack and add a claim.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p claims-mysql up -d --wait
curl -s -X POST localhost:8080/claims -H 'Content-Type: application/json' -d '{"policy":"HLT-1","amount":900}'

Easy Task 3: Mongo

Why: Same image.

What to do: Run the Mongo stack.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p claims-mongo up -d --wait
curl -s localhost:8080/claims

Medium Task 4: Bad input

Why: Validation.

What to do: Post a claim with no policy and read the 400.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
curl -s -i -X POST localhost:8080/claims -H 'Content-Type: application/json' -d '{"amount":5}' | head -1

Medium Task 5: Chiseled

Why: Size.

What to do: Build the chiseled image and compare.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker build -f Dockerfile.chiseled -t dotnet-claims:chiseled .
docker image ls dotnet-claims

Medium Task 6: Env config

Why: 12-factor.

What to do: Change the log level with an env var only.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
# in compose.mysql.yaml under environment
Logging__LogLevel__Default: Debug
docker compose -f compose.mysql.yaml -p claims-mysql up -d

Medium Task 7: Persist

Why: Volumes.

What to do: Restart the stack without -v and prove claims remain.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -f compose.mysql.yaml -p claims-mysql down
docker compose -f compose.mysql.yaml -p claims-mysql up -d --wait
curl -s localhost:8080/claims

Hard Task 8: DB health

Why: Health checks.

What to do: Add a DbContext health check.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
builder.Services.AddHealthChecks().AddDbContextCheck(ClaimsDb)
# package: Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore

Hard Task 9: Multi-arch

Why: ARM.

What to do: Build for amd64 and arm64.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker buildx build --platform linux/amd64,linux/arm64 -t dotnet-claims:multi .

Easy Task 10: Clean

Why: Habit.

What to do: Remove stacks and images.

How:
  1. Use a VM or laptop with Docker and Compose.
  2. Create the dotnet-claims folder with the files from this lesson.
  3. Run the steps and compare with the solution.
Solution
docker compose -f compose.mongo.yaml -p claims-mongo down -v
docker compose -f compose.mysql.yaml -p claims-mysql down -v
docker image rm dotnet-claims:1.0 dotnet-claims:chiseled

Homework: try at home

Add GET /claims/{id} for MySQL, return 404 when the claim is missing, and add a second health check that also pings the database (AddDbContextCheck).

Recap

  • Build with the SDK image, run on aspnet or chiseled.
  • .NET 8 images listen on 8080 and have a non-root app user.
  • Connection strings come from env vars with double underscores.
  • EF Core with Pomelo for MySQL, MongoDB.Driver for Mongo.
  • Chiseled images halve the size and remove the shell.
  • Keep bin/ and obj/ out of the build context.

FAQ

Q. Can I build this on Windows?

Yes. Docker Desktop builds Linux images on Windows. The same Dockerfile works.

Q. Windows containers or Linux containers?

Use Linux containers for .NET 8 unless the app needs Windows-only libraries.

Q. Can I skip the Dockerfile?

Yes. dotnet publish /t:PublishContainer builds an image with the SDK container support.

Q. Should I use EnsureCreated in production?

No. Use EF Core migrations (dotnet ef migrations) as a one-off job.

Q. Why is decimal used for Amount?

Money needs exact decimals; double can round.

Q. How do I attach the Visual Studio debugger?

Visual Studio container tools build a debug image; for this course use logs and curl.

Interview questions

  1. How do you containerise an ASP.NET Core app?

    Model answer: Multi-stage: sdk to restore and publish, aspnet or chiseled runtime, non-root APP_UID, port 8080, config by env vars.

  2. How does .NET read nested settings from environment variables?

    Model answer: Double underscore separates levels, for example ConnectionStrings__Main.

  3. Why is InvariantGlobalization useful in containers?

    Model answer: It removes the ICU dependency so the app runs on minimal images.

  4. What changed in .NET 8 container images?

    Model answer: Default port 8080 instead of 80, and a built-in non-root app user.

  5. How do you run EF Core migrations in a Docker deployment?

    Model answer: Build a migration bundle (dotnet ef migrations bundle) and run it as a one-off container before the app.

Quick quiz

Q1. Why two stages for .NET?

The SDK (850 MB) is only needed to build. The aspnet runtime (220 MB) is enough to run.

Q2. What port does .NET 8 listen on in containers?

8080 by default.

Q3. What does ConnectionStrings__Claims mean?

The double underscore maps to the nested config key ConnectionStrings:Claims.

Q4. What is a chiseled image?

A cut-down Ubuntu image with no shell or package manager, non-root by default.

Q5. Why add bin/ and obj/ to .dockerignore?

So build output from the laptop never mixes with the Linux build.

Samajla ka?

Ravi Sir's Cards for this lesson

मराठी cards