Ship an ASP.NET service on a runtime image with no shell, no package manager and a non-root user baked in — Microsoft’s Chiseled Ubuntu images give .NET the distroless treatment, and the whole thing is a two-stage Dockerfile you can read in one screen.
The default mcr.microsoft.com/dotnet/aspnet:8.0 image is a perfectly serviceable Debian userland: a shell, a package manager, coreutils, the lot. Most of that is machinery your service never touches at runtime, and every package in it is another line item a vulnerability scanner can flag and another tool an attacker can reach for after a breakout. A chiseled image strips the base down to almost nothing — the .NET runtime dependencies and little else. Fewer packages means fewer CVEs on the report and a materially smaller attack surface. This is how you build one.
What “chiseled” actually removes
Chiseled images are built with Canonical’s chisel tool, which installs only the specific files a set of Ubuntu packages need rather than the whole package. The result, for the .NET runtime, is a base with no /bin/sh, no apt or dpkg, no package manager of any kind, and only the shared libraries the runtime actually links against. Two defaults are worth knowing before you write a line of Dockerfile, because they change how the image behaves and they are already set for you:
Free · 4 minutes
Would you survive contact with a determined attacker — or an auditor?
Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.
- It runs as a non-root user by default. The chiseled runtime-deps base sets
USER $APP_UIDwhereAPP_UID=1654— theappuser. You do not add aUSERline; it is already there. - It listens on port 8080, not 80. The base sets
ASPNETCORE_HTTP_PORTS=8080. Binding port 80 needs root, which would defeat the non-root default, so Microsoft moved the listener to an unprivileged port in .NET 8.
Both of those are inherited from the base image, so your Dockerfile stays short and you are not fighting the defaults to get a rootless container. That is the whole point: the secure posture is the path of least resistance.
The Dockerfile
A standard multi-stage build: compile against the full SDK image, then copy the published output onto the chiseled runtime. Nothing from the SDK stage — compilers, NuGet caches, the shell — survives into the final image.
# syntax=docker/dockerfile:1
# --- build stage: full SDK, discarded from the final image ---
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Restore first so the layer caches until the project files change
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
# --- runtime stage: chiseled, no shell, no package manager ---
FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled AS final
WORKDIR /app
COPY --from=build /app ./
# No USER line and no EXPOSE gymnastics: the chiseled base already
# runs as the non-root 'app' user (UID 1654) and defaults to port 8080.
ENTRYPOINT ["dotnet", "MyService.dll"]
Build and run it as you would any image. The service comes up on 8080 as an unprivileged user; map it with -p 8080:8080. If you publish with ContainerBaseImage in the project file instead, point it at the same 8.0-jammy-chiseled tag and dotnet publish /t:PublishContainer produces the equivalent without a Dockerfile at all.
Globalization and the -extra variant
Here is the trap that catches people on the first run. The plain chiseled base ships no ICU and no tzdata. Any culture-sensitive operation — formatting a currency, parsing a localised date, sorting with a culture, converting a time zone — will either throw or silently fall back, depending on how your code is written. You have two honest options:
- Run in invariant globalization mode if your service genuinely does not need cultures — an internal API dealing in invariant strings and UTC, say. Set
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1(orInvariantGlobalizationin the project). Do this deliberately, not by accident, because invariant mode changes string comparison and formatting behaviour. - Use the
-extravariant if you do need globalization. Themcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled-extratag adds theicuandtzdatapackages back on top of the chiseled base. It is a little larger than plain chiseled but still far leaner than the full Debian image, and it keeps the no-shell, non-root posture.
Decide which of those you are on the basis of what your code actually does, not on a guess. A service that formats prices for a UI needs ICU; a message-shovelling worker usually does not. There is also a -composite variant that trims further by compiling the framework assemblies together, at the cost of some flexibility — worth a look once the basics are working, not before.
Prove the shrink with a scan
Claims about size and CVEs are worth nothing unmeasured. Build both variants and compare, then scan with Trivy:
# Size comparison
docker images myservice --format "{{.Tag}}\t{{.Size}}"
# Scan for OS and library vulnerabilities, high and critical only,
# and fail the build if any are found
trivy image --severity HIGH,CRITICAL --exit-code 1 myservice:chiseled
The chiseled image typically reports a fraction of the operating-system package vulnerabilities of the standard image, simply because most of the packages a scanner would flag are no longer present. That --exit-code 1 is the point at which this becomes a control rather than a nice number: wire the same scan into the pipeline so a regression fails the build, the same way you would enforce a pre-commit baseline that actually runs. A shrink you do not measure on every build will quietly un-shrink the first time someone reaches for a heavier base to fix an unrelated problem.
Debugging a container with no shell
The obvious objection lands the first time something breaks in production: docker exec -it <container> sh returns “no such file”, because there is no shell to exec into. That is a feature, not a regression — the same absence that stops you poking around stops an attacker doing it too — but you still need a way in when you need one. The options, roughly in order of preference:
- Ephemeral debug containers. On Kubernetes,
kubectl debugattaches a throwaway container running a full toolbox image into the same process namespace as your running pod, without rebuilding or restarting it. Your image stays chiseled; the tools live in the ephemeral container and vanish when you are done. docker debug(Docker Desktop) does the equivalent locally, layering a shell and utilities over a running container that has neither.- Reproduce on the
-extraor standard tag locally. For a bug you can trigger off-cluster, rebuild the final stage on a fatter base for the repro session only, and ship chiseled.
The deeper answer is that a container you cannot shell into forces the discipline you should have anyway: structured logs, health endpoints, metrics and traces that tell you what is happening without a prompt inside the box. If your only debugging strategy is an interactive shell in production, the missing shell is exposing a gap in your observability, not in the image — the same instinct that makes you track deployment and change-failure metrics rather than debug by intuition. And because there is no writable package manager and no shell, there is nowhere convenient to stash a fetched secret at runtime either, which pairs well with keeping credentials out of the image and treating API keys as something injected and rotated rather than baked in.
Chiseled is not exotic and it is not a research project. It is two lines of tag choice and a decision about globalization, in exchange for a smaller image, a cleaner scan and a non-root default you did not have to argue anyone into. The only real work is being honest about whether you need ICU — everything else the base already did for you.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.