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.
How: step by step
- Create the project files. claims.csproj and Program.cs.
- Write a multi-stage Dockerfile. sdk build stage, aspnet runtime.
- Run with MySQL. EF Core creates the table.
- Run with MongoDB. Same image, DB_KIND=mongo.
- Add health checks. /healthz and a Compose test.
- Try the chiseled image. Smaller, no shell.
- Clean up. down -v.
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)
-
Make the folder
mkdir dotnet-claims && cd dotnet-claimsWhat it does: Creates the project folder. Add every file from the examples.
(no output) -
Restore packages
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:8.0 dotnet restoreWhat it does: Checks the csproj and downloads NuGet packages.
Restored /src/claims.csproj -
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 -
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:{}] -
Start MySQL stack
docker compose -f compose.mysql.yaml -p claims-mysql up -d --waitWhat it does: MySQL, then the API.
Container claims-mysql-api-1 Healthy -
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} -
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 -
Read logs
docker compose -p claims-mysql logs api --tail 5What it does: Shows .NET logs.
claims api ready, db=mysql -
Switch to Mongo
docker compose -p claims-mysql down docker compose -f compose.mongo.yaml -p claims-mongo up -d --waitWhat it does: Same image, Mongo driver.
Container claims-mongo-api-1 Started -
Build chiseled
docker build -f Dockerfile.chiseled -t dotnet-claims:chiseled .What it does: Makes the smaller image.
dotnet-claims chiseled 118MB -
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:chiseledWhat 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
- Check Server=db in the connection string.
- Use depends_on with service_healthy.
- 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
- Add InvariantGlobalization true to the csproj.
- 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
- Remove ASPNETCORE_URLS=http://+:80.
- 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
- Make sure RUN dotnet restore comes first.
- 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
- Add ?authSource=admin.
- 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.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker build -t dotnet-claims:1.0 .
docker run --rm --entrypoint id dotnet-claims:1.0Easy Task 2: MySQL
Why: Basics.
What to do: Start the MySQL stack and add a claim.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- 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.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- 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/claimsMedium Task 4: Bad input
Why: Validation.
What to do: Post a claim with no policy and read the 400.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- 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 -1Medium Task 5: Chiseled
Why: Size.
What to do: Build the chiseled image and compare.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker build -f Dockerfile.chiseled -t dotnet-claims:chiseled .
docker image ls dotnet-claimsMedium Task 6: Env config
Why: 12-factor.
What to do: Change the log level with an env var only.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- 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 -dMedium Task 7: Persist
Why: Volumes.
What to do: Restart the stack without -v and prove claims remain.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- 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/claimsHard Task 8: DB health
Why: Health checks.
What to do: Add a DbContext health check.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
builder.Services.AddHealthChecks().AddDbContextCheck(ClaimsDb)
# package: Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCoreHard Task 9: Multi-arch
Why: ARM.
What to do: Build for amd64 and arm64.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- 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.
- Use a VM or laptop with Docker and Compose.
- Create the dotnet-claims folder with the files from this lesson.
- 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:chiseledHomework: 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
-
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.
-
How does .NET read nested settings from environment variables?
Model answer: Double underscore separates levels, for example ConnectionStrings__Main.
-
Why is InvariantGlobalization useful in containers?
Model answer: It removes the ICU dependency so the app runs on minimal images.
-
What changed in .NET 8 container images?
Model answer: Default port 8080 instead of 80, and a built-in non-root app user.
-
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?
Revision card
Interview card