Containerizing ASP.NET Core: .NET 10 Images, Chiseled, Non-root, Port 8080 and Globalization
ASP.NET Core images listen on port 8080 and carry the non-root app user through APP_UID; a chiseled image, however, has no ICU or tzdata, so code that asks for Turkish culture or the Europe/Istanbul time zone returns 500. We containerize Bookstore.Api, compare image families, fix that bug with chiseled-extra, and see configuration through environment variables, a health check without a shell and how a memory limit affects the GC, all with real output.
Version note: The examples in this post were run with Docker Engine 29.9.3, .NET SDK 10.0.404, the ASP.NET Core Runtime 10.0.15 images (mcr.microsoft.com/dotnet/aspnet:10.0-*) and Microsoft.AspNetCore.OpenApi 10.0.15. Sizes were taken from linux/amd64 images.
Guess First
Bookstore.Api sorts books by Turkish rules (tr-TR) and shows the store's time in the Europe/Istanbul time zone. On the developer machine everything is right. You containerized the same code with the mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled image. What does GET /books return: (a) the same result, (b) a sort order with "Çalıkuşu" and "İnce Memed" dropped to the end, (c) a 500?
Bookstore.Api and the First Dockerfile
In this post we containerize the main application of Bookstore, the online bookstore used as the shared example across this site's series: its ASP.NET Core Web API, Bookstore.Api. We left the database connection to the volume and network posts (11 and 12) and started small: the catalog lives in memory for now, with two endpoints and a health check. The part needed to see container-specific behavior is this:
public sealed class BookCatalog(IOptions<StoreOptions> options)
{
private static readonly Book[] Books =
[
new("9780441172719", "Dune", "Frank Herbert", 420.00m),
new("9789750719387", "İnce Memed", "Yaşar Kemal", 310.00m),
new("9789750738609", "Çalıkuşu", "Reşat Nuri Güntekin", 240.00m),
new("9789754700114", "Saatleri Ayarlama Enstitüsü", "Ahmet Hamdi Tanpınar", 280.00m),
new("9780141439587", "Emma", "Jane Austen", 180.00m),
new("9789750722752", "Kürk Mantolu Madonna", "Sabahattin Ali", 150.00m),
];
public IReadOnlyList<BookView> SortedByTitle()
{
CultureInfo culture = CultureInfo.GetCultureInfo(options.Value.Culture);
StringComparer comparer = StringComparer.Create(culture, ignoreCase: false);
return [.. Books
.OrderBy(b => b.Title, comparer)
.Select(b => new BookView(b.Isbn, b.Title, b.Title.ToUpper(culture), b.PriceTry))];
}
}
app.MapHealthChecks("/health");
app.MapGet("/books", (BookCatalog catalog) => catalog.SortedByTitle());
app.MapGet("/store/clock", (IOptions<StoreOptions> options) =>
{
TimeZoneInfo zone = TimeZoneInfo.FindSystemTimeZoneById(options.Value.TimeZone);
DateTimeOffset local = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone);
return new { zone = zone.Id, localTime = local.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture) };
});
app.Lifetime.ApplicationStarted.Register(() =>
app.Logger.LogInformation("Environment: {Environment}, GC memory limit: {LimitMb} MB, processors: {Cpus}",
app.Environment.EnvironmentName,
GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1024 / 1024,
Environment.ProcessorCount));
ShelfLabel is the upper-case label printed for the shelf: with Turkish rules "Saatleri Ayarlama Enstitüsü" must become "SAATLERİ AYARLAMA ENSTİTÜSÜ". Culture and TimeZone come from the Store section of appsettings.json. The Dockerfile is the structure from post 7 adapted to the API:
# syntax=docker/dockerfile:1
ARG DOTNET_VERSION=10.0
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
ARG APP_VERSION=1.0.0
WORKDIR /src
COPY global.json ./
COPY src/Bookstore.Api/Bookstore.Api.csproj src/Bookstore.Api/
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore src/Bookstore.Api/Bookstore.Api.csproj -a $TARGETARCH
COPY src/Bookstore.Api/ src/Bookstore.Api/
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -c $BUILD_CONFIGURATION -a $TARGETARCH \
--no-restore -o /app -p:Version=$APP_VERSION
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-noble-chiseled AS final
ARG APP_VERSION=1.0.0
WORKDIR /app
COPY --from=build /app ./
LABEL org.opencontainers.image.title="Bookstore.Api" \
org.opencontainers.image.version=$APP_VERSION
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]
There is one difference from the Bookstore.Tools Dockerfile of post 7: we restore with -a $TARGETARCH, so the target architecture's restore also stays in its own layer and publish can run with --no-restore. We copy only the Api's csproj and folder; a file changed in Tools does not break the Api's cache. We will see a problem that adding the Api to the solution caused in the Tools Dockerfile in the Pitfalls section.
We added an api target to the docker-bake.hcl we started in post 6 and put it into the default and ci groups. The lint target is now a matrix: the same definition produces a separate check target for each application (lint-tools, lint-api):
target "api" {
context = "."
dockerfile = "src/Bookstore.Api/Dockerfile"
target = "final"
args = {
APP_VERSION = TAG
}
tags = ["dockerseries-bookstore-api:${TAG}"]
}
target "lint" {
name = "lint-${app}"
matrix = { app = ["tools", "api"] }
inherits = [app]
call = "check"
}
docker buildx bake ci
#37 naming to docker.io/library/dockerseries-bookstore-tools:1.0.0 done
#41 naming to docker.io/library/dockerseries-bookstore-api:1.0.0 done
lint-api
Check complete, no warnings found.
lint-tools
Check complete, no warnings found.
Choosing the Image Family
The aspnet image carries the ASP.NET Core runtime and comes in several variants. With .NET 10, tags that do not name an operating system, such as 10.0, point to Ubuntu 24.04 ("Noble") instead of Debian, and Debian images are no longer published. Microsoft's reason is support periods: Ubuntu releases are supported longer than a .NET release. We looked at the linux/amd64 image config and size of the variants:
for t in 10.0 10.0-alpine 10.0-azurelinux3.0-distroless 10.0-noble-chiseled 10.0-noble-chiseled-extra 10.0-noble-chiseled-composite; do
docker image inspect --platform linux/amd64 "mcr.microsoft.com/dotnet/aspnet:$t" \
--format "$t User={{.Config.User}} Env={{json .Config.Env}}"
done
10.0 User= Env=["PATH=...","APP_UID=1654","ASPNETCORE_HTTP_PORTS=8080","DOTNET_RUNNING_IN_CONTAINER=true","DOTNET_VERSION=10.0.15","ASPNET_VERSION=10.0.15"]
10.0-noble-chiseled User=1654 Env=["PATH=...","APP_UID=1654","ASPNETCORE_HTTP_PORTS=8080","DOTNET_RUNNING_IN_CONTAINER=true","DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true","DOTNET_VERSION=10.0.15","ASPNET_VERSION=10.0.15"]
10.0-noble-chiseled-extra User=1654 Env=["PATH=...","APP_UID=1654","ASPNETCORE_HTTP_PORTS=8080","DOTNET_RUNNING_IN_CONTAINER=true","DOTNET_VERSION=10.0.15","ASPNET_VERSION=10.0.15"]
(I shortened the PATH value and left out three lines; they are all in the table.) The Globalization column shows whether the image has ICU and tzdata; we checked whether there is a shell with docker run --entrypoint sh:
| Tag | User | Globalization | Shell | DISK USAGE / CONTENT SIZE |
|---|---|---|---|---|
10.0 (Ubuntu 24.04) | root | ICU and tzdata | yes | 342MB / 97.7MB |
10.0-alpine | root | invariant | yes | 178MB / 55.7MB |
10.0-azurelinux3.0-distroless | 1654 | invariant | no | 209MB / 63.8MB |
10.0-noble-chiseled | 1654 | invariant | no | 182MB / 56.7MB |
10.0-noble-chiseled-extra | 1654 | ICU and tzdata | no | 239MB / 72.6MB |
10.0-noble-chiseled-composite | 1654 | invariant | no | 181MB / 56.1MB |
A short way to choose: if you need no shell and no package manager (production should not), chiseled; if you need globalization, chiseled-extra; if you have to install a native package with apt-get during the build, the Ubuntu-based 10.0. Alpine uses musl libc and can surprise you with native dependencies; Azure Linux distroless is for teams standardized on the Azure ecosystem. In the composite variant the framework assemblies are combined into a single ReadyToRun binary; according to Microsoft's documentation, startup gets faster because there is less JIT work. We started Bookstore.Api on chiseled.
Port 8080 and a Non-root User
Two environment variables in the image config sum up this section. ASPNETCORE_HTTP_PORTS=8080 tells Kestrel which port to listen on; since .NET 8 it has been 8080 instead of 80 in all official images. APP_UID=1654 is the uid of the app user that ships with the images. Chiseled and distroless images already start with this user (User=1654); the User field of the Ubuntu and Alpine images is empty, which means you run as root if you do not write a USER line. That is why USER $APP_UID in the Dockerfile gives the same result on every variant, and the user does not silently fall back to root when the image changes. We start the container:
docker build -f src/Bookstore.Api/Dockerfile -t dockerseries-bookstore-api:1.0.0 .
docker run -d --name dockerseries-api -p 8080:8080 dockerseries-bookstore-api:1.0.0
docker logs dockerseries-api
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:8080
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /app
Kestrel listens on 8080 on all interfaces ([::]); had it bound to localhost inside the container, you could not reach it from the host (we will see this in post 12). -p 8080:8080 connects the host's 8080 to the container's 8080; the EXPOSE 8080 in the Dockerfile is, as we saw in post 5, only documentation. To change the port, -e ASPNETCORE_HTTP_PORTS=5000 is enough; ASPNETCORE_URLS still works too and overrides ASPNETCORE_HTTP_PORTS when set; when we tried it, Kestrel logged it with the warning Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. We will cover the move from port 80 to 8080 in older setups in post 15.
Configuration Through Environment Variables
ASP.NET Core's configuration system reads environment variables, and __ (two underscores) is the section separator: Store__TimeZone overrides Store:TimeZone in appsettings.json. Provider order and the options pattern are covered in the ASP.NET Core Web API series post "Configuration and the Options Pattern in ASP.NET Core: Provider Order, User Secrets and Validation"; what matters in a container is that the same image can behave differently in each environment through environment variables alone. Without ASPNETCORE_ENVIRONMENT the environment is Production, and Bookstore.Api exposes the OpenAPI document only in Development:
curl -s -o /dev/null -w "openapi: %{http_code}\n" localhost:8080/openapi/v1.json
docker rm -f dockerseries-api
docker run -d --name dockerseries-api -p 8080:8080 \
-e ASPNETCORE_ENVIRONMENT=Development -e Store__TimeZone=UTC dockerseries-bookstore-api:1.0.0
curl -s -o /dev/null -w "openapi: %{http_code}\n" localhost:8080/openapi/v1.json
curl -s localhost:8080/store/clock
openapi: 404
openapi: 200
{"zone":"UTC","localTime":"2026-11-14 11:30 +00:00"}
The database connection will come the same way: in post 12 we connect to a SQL Server container with -e ConnectionStrings__Default="Server=bookstore-db;...". Because environment variables can be read with docker inspect, values that contain passwords should come from a secret store in production; we will talk about that in post 13 and in the Configuration and Secret Management series.
Turkish and Time Zones on Chiseled: A Bookstore Bug
Now the answer to the first question. We sent requests to the container built with the chiseled image:
curl -s -i localhost:8080/books | head -1
curl -s -i localhost:8080/store/clock | head -1
docker logs dockerseries-api 2>&1 | grep -E "fail|Exception"
HTTP/1.1 500 Internal Server Error
HTTP/1.1 500 Internal Server Error
fail: Microsoft.AspNetCore.Server.Kestrel[13]
System.Globalization.CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
fail: Microsoft.AspNetCore.Server.Kestrel[13]
System.TimeZoneNotFoundException: The time zone ID 'Europe/Istanbul' was not found on the local computer.
---> System.IO.DirectoryNotFoundException: Could not find a part of the path '/usr/share/zoneinfo/Europe/Istanbul'.
The answer is (c). The chiseled image has no ICU, it ships with DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true, and in invariant mode .NET throws when a culture such as tr-TR is requested. Time zone data (tzdata, /usr/share/zoneinfo) is missing too. On the developer machine both come with the operating system, so the error only shows up in the container.
At this point the "fix" suggested most often online is to silence the exception: DOTNET_SYSTEM_GLOBALIZATION_PREDEFINED_CULTURES_ONLY=false. We tried it:
docker run -d --name dockerseries-api -p 8080:8080 \
-e DOTNET_SYSTEM_GLOBALIZATION_PREDEFINED_CULTURES_ONLY=false dockerseries-bookstore-api:chiseled
curl -s localhost:8080/books | jq -r '.[] | "\(.title) | \(.shelfLabel)"'
Dune | DUNE
Emma | EMMA
Kürk Mantolu Madonna | KÜRK MANTOLU MADONNA
Saatleri Ayarlama Enstitüsü | SAATLERI AYARLAMA ENSTITÜSÜ
Çalıkuşu | ÇALıKUŞU
İnce Memed | İNCE MEMED
The 500 is gone, the bug stayed and is silent now. The sort is ordinal: "Çalıkuşu" and "İnce Memed" are at the end because their Unicode code points are greater than the Latin letters. The upper-casing does not know Turkish rules: "SAATLERI" and "ENSTITÜSÜ" have a dotless I, and the ı in the middle of "ÇALıKUŞU" was not converted at all. These labels would have been printed for the shelves. The real fix is to pick the image that carries what the application needs; one line in the Dockerfile:
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-noble-chiseled-extra AS final
curl -s localhost:8080/books | jq -r '.[] | "\(.title) | \(.shelfLabel)"'
curl -s localhost:8080/store/clock
Çalıkuşu | ÇALIKUŞU
Dune | DUNE
Emma | EMMA
İnce Memed | İNCE MEMED
Kürk Mantolu Madonna | KÜRK MANTOLU MADONNA
Saatleri Ayarlama Enstitüsü | SAATLERİ AYARLAMA ENSTİTÜSÜ
{"zone":"Europe/Istanbul","localTime":"2026-11-14 14:29 +03:00"}
Ç comes after C, İ after I, the upper case is right and the time is +03:00. The price is size: the Bookstore.Api image is 183 MB / 57 MB with chiseled and 240 MB / 73 MB with chiseled-extra. If the application really does no culture-sensitive work, you can go the other way: write <InvariantGlobalization>true</InvariantGlobalization> in the csproj and the developer machine runs in invariant mode as well, so the same error shows up locally before it reaches a container. What matters is that both environments run in the same mode.
Health Checks in an Image Without a Shell
The HEALTHCHECK instruction in a Dockerfile tells Docker how to measure the container's health. The classic example is HEALTHCHECK CMD curl -f http://localhost:8080/health, but the chiseled image has neither curl nor a /bin/sh to run the shell form. The only program that can already run inside the container is dotnet; we added a small probe mode to the application itself:
namespace Bookstore.Api;
// "dotnet Bookstore.Api.dll healthcheck": a probe for images that have no shell or curl
public static class HealthProbe
{
public static async Task<int> RunAsync()
{
string port = (Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS") ?? "8080").Split(';')[0];
using HttpClient http = new() { Timeout = TimeSpan.FromSeconds(3) };
try
{
using HttpResponseMessage response = await http.GetAsync($"http://localhost:{port}/health");
return response.IsSuccessStatusCode ? 0 : 1;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return 1;
}
}
}
if (args is ["healthcheck"])
{
return await HealthProbe.RunAsync();
}
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["dotnet", "Bookstore.Api.dll", "healthcheck"]
The exec form is mandatory because there is no shell. The probe sends a request to /health, the endpoint we set up in the ASP.NET Core Web API series post "Health Checks in ASP.NET Core Web API: Liveness, Readiness and Database Checks":
docker ps --format "table {{.Names}}\t{{.Status}}"
docker inspect dockerseries-api --format "{{json .State.Health}}" | jq ".Status, .Log[-1]"
NAMES STATUS
dockerseries-api Up 3 seconds (health: starting)
NAMES STATUS
dockerseries-api Up 15 seconds (healthy)
"healthy"
{
"Start": "2026-11-14T11:30:23.736089837Z",
"End": "2026-11-14T11:30:24.326524087Z",
"ExitCode": 0,
"Output": ""
}
This approach has a cost: every 30 seconds a new .NET process starts and exits (about 0.6 seconds between Start and End in the log); that time and memory come out of the container's limit. Docker's health status shows up in docker ps and is used by features such as depends_on: condition: service_healthy in Docker Compose. Kubernetes never reads the Dockerfile HEALTHCHECK; its own probes send HTTP requests directly and start no process inside the container. The Kubernetes series post "Kubernetes Health Probes: Startup, Liveness, Readiness and ASP.NET Core Health Checks" covers that. If the image will only run on Kubernetes, leaving out the HEALTHCHECK line is a reasonable choice too.
Memory Limits and the GC
When you limit a container with --memory and --cpus, .NET reads the limits from the cgroup and tunes the GC accordingly. Bookstore.Api logs the memory the GC sees and the processor count at startup:
docker run -d --name dockerseries-api -p 8080:8080 --memory 256m --cpus 1 dockerseries-bookstore-api:1.0.0
docker logs dockerseries-api 2>&1 | tail -2
info: Bookstore.Api[0]
Environment: Production, GC memory limit: 192 MB, processors: 1
75% of the 256 MB limit, 192 MB, became the upper bound of the GC heap; the rest is left for native memory, thread stacks and the runtime itself. With --cpus 1, Environment.ProcessorCount returned 1, which affects the GC mode and the initial size of the thread pool. Without a limit, .NET sees all the memory of the machine (on Docker Desktop, of the WSL 2 VM), and on a host shared with other work the container can grow much more than expected. Changing the heap limit with settings such as DOTNET_GCHeapHardLimitPercent, and diagnosing OOMKilled, are in the Kubernetes series post "Resource Requests and Limits: QoS, OOMKilled, CPU Throttling and the .NET GC in Containers"; we will examine a container that dies with 137 in post 14.
In Production
Terminate TLS in the reverse proxy or load balancer in front of the container, not in the container. The image listens only on HTTP 8080; certificates, renewal and HTTP/2–HTTP/3 settings are the proxy's job. This also keeps the certificate out of the image.
Pin the base image tag on purpose. aspnet:10.0-noble-chiseled-extra is updated with every .NET patch (roughly monthly) and brings security fixes; with the digest pin and automatic updates from post 4, you see that update as a pull request.
Keep the application's framework version compatible with the runtime image. The image carries ASPNET_VERSION=10.0.15; if you tie the app to a package that needs a newer patch, the runtime may lag behind. docker run --rm --entrypoint dotnet <image> --list-runtimes shows the runtimes in the image.
You built the image with a non-root user, but the environment that runs the container can override it (docker run --user 0, Kubernetes securityContext). We will discuss these settings and extra layers such as --read-only and --cap-drop in post 13.
Common Pitfalls
Putting appsettings.Development.json into the image. dotnet publish copies this file to the output by default. We looked inside the container:
docker create --name dockerseries-inspect dockerseries-bookstore-api:1.0.0
docker export dockerseries-inspect | tar -tf - | grep "^app/appsettings"
docker rm dockerseries-inspect
app/appsettings.Development.json
app/appsettings.json
If someone starts the container with ASPNETCORE_ENVIRONMENT=Development, the development settings (verbose log levels, local connection addresses, sometimes test passwords) kick in. With the line we added to the csproj the file never enters the publish output; the same command now lists only app/appsettings.json:
<ItemGroup>
<!-- Development settings stay on the developer machine, never in the image -->
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
</ItemGroup>
Putting the development certificate into the image. Copying the certificate made with dotnet dev-certs and its password into the image and enabling HTTPS in Kestrel makes a local test easier, but hands a private key to everyone who pulls the image. In production TLS lives in the proxy; in local development let the container run on HTTP.
Restoring through the solution file while copying only some projects. After we added the Api to Bookstore.slnx, the Tools Dockerfile from post 7 stopped building:
error MSB3202: The project file "/src/src/Bookstore.Api/Bookstore.Api.csproj" was not found. [/src/Bookstore.slnx]
The Tools Dockerfile said dotnet restore Bookstore.slnx but did not copy the Api's csproj. We changed it to restore through the test project instead of the solution (the test project references Tools, so both are restored) and stopped copying the solution. The rule: what you restore in a Dockerfile must match the csproj files you copy.
Silencing the globalization error with PREDEFINED_CULTURES_ONLY=false. As we saw above, the exception goes away and the wrong sort order and wrong upper case stay. Either pick an image with culture support or run the application in invariant mode on purpose in every environment.
A Small Experiment
★☆☆ Make the final stage aspnet:10.0 (Ubuntu) and delete the USER $APP_UID line. What does docker exec dockerseries-api id show? What changes when you put the line back?
★★☆ Add <InvariantGlobalization>true</InvariantGlobalization> to the csproj and run the app locally with dotnet run. Does /books return 500 locally now too? If you move the image back to chiseled, does the container behave the same as local?
★★★ Start the container with --memory 128m and send hundreds of requests to /books with a load tool (for example hey or bombardier). Where does memory settle in docker stats, and does the container die? Repeat with DOTNET_GCHeapHardLimitPercent=50.
The Bookstore.Api Dockerfile is ready. In the next post we turn the same API into an image without writing any Dockerfile, using the .NET SDK's dotnet publish /t:PublishContainer support, and compare the two paths by size, layers and build time.