Small, Hardened Images

Chiselled and distroless base images, a non-root user, a read-only root filesystem, and why smaller is also safer.

SyntaxFROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled

Everything in an image is something an attacker who reaches the container can use. A shell lets them explore. A package manager lets them install tools. Remove both and most of what a foothold is worth disappears. That is the argument for a small image: the attack surface, the set of things that can be attacked, shrinks with the file count.

Chiselled and distroless bases

Microsoft publishes stripped base images: 10.0-noble-chiseled on Ubuntu and 10.0-azurelinux3.0-distroless. They contain the files .NET needs and nothing else. No shell, no package manager, and they run as a non-root user by default. Swap the final FROM line and nothing else changes.

Non-root

The ordinary images include a user named app with UID 1654, exposed as the environment variable APP_UID, but they still run as root unless you say otherwise. Add USER $APP_UID as the last instruction before ENTRYPOINT.

Read-only root filesystem

A container started with --read-only cannot have its own files modified, so a dropped payload has nowhere to land. The app still needs somewhere to write temporary files, which is what --tmpfs /tmp gives it.

What trips people up

A non-root user cannot bind to a port below 1024. Setting the port back to 80 undoes the non-root change.

Example

Example · csharp
// Program.cs - a read-only root filesystem is a container setting, but the
// app has to cooperate with it. It may only write where a writable mount
// exists. Path.GetTempPath() is /tmp on Linux, which `--tmpfs /tmp` keeps
// writable even under `docker run --read-only`.
string path = Path.Combine(Path.GetTempPath(), "inventory-cache.txt");

try
{
    File.WriteAllText(path, "warm");
    Console.WriteLine("temp write: ok");
    File.Delete(path);
}
catch (IOException)
{
    // The filesystem under that path is mounted read-only.
    Console.WriteLine("temp write: refused");
}
catch (UnauthorizedAccessException)
{
    // The path exists and is writable, but not by this user.
    Console.WriteLine("temp write: refused");
}

// Run on your own machine, where the temp directory is writable:

// Output:
// temp write: ok

When to use it

  • Cut a service's image size and CVE count without changing any application code.
  • Pass a cluster policy that rejects containers running as root.
  • Make a compromised container useless for exploration by removing its shell and write access.

More examples

The chiselled final stage

The same Dockerfile as the previous lesson with one line changed. Use it once the service runs correctly on the ordinary image, and remember the project needs InvariantGlobalization set to true for a chiselled base, since there is no ICU inside.

Example · dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY InventoryApi/InventoryApi.csproj InventoryApi/
RUN dotnet restore InventoryApi/InventoryApi.csproj
COPY . .
RUN dotnet publish InventoryApi/InventoryApi.csproj \
    -c Release -o /app/publish --no-restore

# Chiselled: no shell, no package manager, non-root already. There is no
# chiselled sdk image, which is why the build stage above is unchanged.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app/publish .

# No USER line is needed. This base image is non-root by default.
EXPOSE 8080
ENTRYPOINT ["dotnet", "InventoryApi.dll"]

Naming the same user two ways

Both select the rootless app user. The UID form is the one to use, because Kubernetes can only enforce its runAsNonRoot check when the user is a number: given a name, it cannot tell whether that name is root. There is more on that in the Kubernetes lessons.

Example · dockerfile
# By name. Readable, and it is the form the .NET 8 release notes used.
USER app

# By UID, read from the APP_UID environment variable the base image sets.
# Same user: app is UID 1654. Prefer this form.
USER $APP_UID

Locking down the running container

Hardening is split between the image and the way it is started, and these are the start-time half. Run this locally before the same settings go into a Kubernetes securityContext, because a missing writable path shows up here in seconds instead of as a crash loop in a cluster.

Example · bash
docker run --rm \
    --read-only \
    --tmpfs /tmp \
    --cap-drop ALL \
    --security-opt no-new-privileges \
    -p 8080:8080 \
    inventory-api:1.4.0

# --read-only            the image's own files cannot be changed
# --tmpfs /tmp           one writable directory, wiped when the container stops
# --cap-drop ALL         give up every Linux privilege; a web app needs none
# --security-opt ...     the process cannot gain privileges it started without

Discussion

  • Be the first to comment on this lesson.