Small, Hardened Images

Choose between noble, Alpine and chiseled .NET images, handle ICU and time zones, debug without a shell, and pin and scan base images.

SyntaxARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra FROM ${RUNTIME_IMAGE} COPY --from=build /app/publish /app ENTRYPOINT ["/app/Orders.Api"]

Chiseled images are Ubuntu images cut down to the files a .NET process needs. There is no shell, no package manager and no apt, and the image starts as the non-root app user (UID 1654) without a USER line. Fewer packages means fewer CVEs to triage, a smaller pull and nothing for an attacker who gets code execution to work with.

Choosing a variant

aspnet tagShell, package managerICU and tzdataStarts asChoose when
10.0 (noble)yesyesrootyou must install OS packages in the final stage
10.0-alpineyes (apk)no, invariant moderootyou want small and accept musl
10.0-noble-chiselednono, invariant modeappthe API is culture-neutral and UTC-only
10.0-noble-chiseled-extranoyesappyou format for cultures or convert time zones

The same suffixes exist for runtime and runtime-deps; a self-contained or Native AOT app goes on runtime-deps:10.0-noble-chiseled.

Globalization invariant mode

Plain chiseled images set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true because they carry no ICU. Every culture then behaves like the invariant culture, string comparison is ordinal, and creating a specific culture such as new CultureInfo("de-DE") throws CultureNotFoundException. Without tzdata, TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin") throws TimeZoneNotFoundException; UTC still works. Microsoft.Data.SqlClient refuses to run in invariant mode at all. These failures appear on the first request that needs them, not at start-up. Decide per service: an API that stores UTC and returns ISO 8601 JSON is fine on plain chiseled; anything that renders invoices for a locale or computes a warehouse cut-off in local time needs -extra. Set InvariantGlobalization to match the image in Directory.Build.props, so the test projects get it too and tests on a laptop fail the same way production would. Set only in the API's .csproj, it lands in the API's runtimeconfig.json, while the test host runs with the test project's own runtimeconfig and keeps ICU.

Living without a shell

  • No RUN in the final stage. Create directories and set permissions in the build stage, then COPY --chown.
  • No HEALTHCHECK CMD curl. Kubernetes httpGet probes are made by the kubelet, so no binary is needed inside.
  • No kubectl exec -- sh. Attach an ephemeral debug container with kubectl debug --target; it shares the process namespace.
  • A preStop exec of sleep fails because there is no sleep binary; use the native sleep action.

Pinning and scanning

A tag such as 10.0-noble-chiseled moves: Microsoft re-publishes it for .NET patches and Ubuntu fixes. Pin by digest (@sha256:...) so a build is reproducible, and let Renovate or Dependabot open a pull request when the digest changes so the update is tested rather than silent. Scan the final image, not the SDK stage, in CI with Trivy or Docker Scout and fail on fixable High and Critical findings. Rebuild on a schedule: an image built six months ago carries six months of unpatched libraries even if your code did not change.

Example

Example · dockerfile
# syntax=docker/dockerfile:1
# CI passes a digest-pinned reference:
#   --build-arg RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra@sha256:<digest>
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
WORKDIR /src
COPY Directory.Build.props Directory.Packages.props ./
COPY src/Orders.Api/Orders.Api.csproj src/Orders.Api/
RUN dotnet restore src/Orders.Api/Orders.Api.csproj -a $TARGETARCH
COPY src/ src/
RUN dotnet publish src/Orders.Api/Orders.Api.csproj -a $TARGETARCH --no-restore \
        -c Release -o /app/publish
# The final image has no shell, so prepare writable folders here.
RUN mkdir -p /staging/invoice-exports

FROM ${RUNTIME_IMAGE} AS final
WORKDIR /app
COPY --from=build /app/publish .
COPY --from=build --chown=1654:1654 /staging/invoice-exports /var/lib/orders/invoice-exports
# Chiseled images already start as app; stating it keeps policy linters quiet.
USER $APP_UID
ENTRYPOINT ["./Orders.Api"]

When to use it

  • Cut the CVE report for the orders API from dozens of OS packages to a handful by moving from aspnet:10.0 to 10.0-noble-chiseled.
  • Keep invoice PDFs rendering amounts as 1.234,50 EUR for German customers by deploying on the -noble-chiseled-extra image.
  • Pin the runtime image by digest and let Renovate open a pull request each time Microsoft publishes a patched 10.0 image.

More examples

A start-up probe for culture and time-zone data

A service that needs culture data checks for it before the host starts, so a wrong base image fails the rollout on the first pod instead of returning 500s for German invoices. <code>GetCultureInfo</code> throws <code>CultureNotFoundException</code> in invariant mode because only the invariant culture is predefined there.

Example · csharp
using System.Globalization;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Fail at start-up, not on the first invoice, when the image lacks ICU or tzdata.
var problems = new List<string>();
try
{
    var german = CultureInfo.GetCultureInfo("de-DE");
    app.Logger.LogInformation("Invoice total renders as {Total}", 1234.5m.ToString("C", german));
}
catch (CultureNotFoundException)
{
    problems.Add("de-DE culture unavailable: globalization invariant mode (no ICU in the image)");
}

try
{
    var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
    var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, berlin);
    app.Logger.LogInformation("Warehouse local time {LocalTime}", local);
}
catch (TimeZoneNotFoundException)
{
    problems.Add("Europe/Berlin not found: no tzdata in the image");
}

if (problems.Count > 0)
{
    foreach (var problem in problems) app.Logger.LogCritical("{Problem}", problem);
    app.Logger.LogCritical("Use the -noble-chiseled-extra base image");
    return 1;
}

app.MapGet("/invoices/{id:int}/total", (int id) =>
    Results.Ok(new { id, total = 1234.5m.ToString("C", CultureInfo.GetCultureInfo("de-DE")) }));

await app.RunAsync();
return 0;

Pin, scan and debug a shell-less image

<code>imagetools inspect</code> prints the manifest digest to pin; Renovate or Dependabot can then bump it with a reviewed pull request. <code>--ignore-unfixed</code> keeps the gate actionable, since a CVE with no released fix cannot be patched by rebuilding. <code>kubectl debug --target</code> gives you tools next to the process without baking them into the image.

Example · bash
# Resolve the digest the tag points to today, then pin it in CI's --build-arg
docker buildx imagetools inspect mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra

# Fail the pipeline on fixable high and critical CVEs in the final image
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
  registry.example.com/shop/orders-api:1.4.0

# There is no shell to exec into:
docker run --rm --entrypoint sh registry.example.com/shop/orders-api:1.4.0
# exec: "sh": executable file not found in $PATH

# Attach a debug container that shares the API container's process namespace
kubectl debug -it orders-api-7c9f6d8b5-xk2lp --image=busybox:1.36 --target=orders-api -- sh
# inside: ps, ls /proc/1/root/app, wget -qO- localhost:8080/health/ready

Make the projects and tests match the image

The property is written into each project's runtimeconfig.json, and a test run, including <code>WebApplicationFactory</code> tests, uses the test project's file, not the API's. Setting it in <code>Directory.Build.props</code> puts the laptop, the test run and the plain chiseled container all in invariant mode, so code that formats with <code>de-DE</code> fails in a unit test instead of in production. If the service needs cultures, remove it and deploy on the <code>-extra</code> image instead.

Example · xml
<!-- Directory.Build.props at the repository root: applies to the API and its test projects -->
<Project>

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- Deployed on aspnet:10.0-noble-chiseled (no ICU): behave the same everywhere -->
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>

</Project>

Discussion

  • Be the first to comment on this lesson.