Safeguard
Container Security

Best practices for containerizing .NET applications securely

.NET 8 gave containers a built-in non-root user and chiseled images that cut one team's CVE count 92% — most Dockerfiles still don't use either.

Safeguard Research Team
Research
6 min read

In November 2023, .NET 8 shipped a change that quietly fixed one of the most common container misconfigurations in the .NET ecosystem: the official mcr.microsoft.com/dotnet Linux images gained a built-in, non-root user named app (UID 1654, derived from 1000 plus the ASCII sum of the string "dotnet"), exposed through the $APP_UID environment variable for a one-line USER $APP_UID instruction. The same release moved ASP.NET Core's default listening port from 80 to 8080, specifically so non-root containers wouldn't need a privileged low port. Around the same time, Canonical's chiseled Ubuntu images reached general availability for .NET 6, 7, and 8 — stripped runtime images with no shell, no package manager, and no curl or wget, meaning a compromised process has nothing to download-and-execute with. In one Microsoft Azure Container Apps team case study, switching a backend API to a chiseled base cut image size from 226MB to 119MB and reduced flagged CVEs from 25 to 2. Despite this, plenty of production .NET Dockerfiles still run as root on a bloated Debian base with secrets baked into ENV lines. Here's what actually changed, and what still needs a deliberate decision.

Which .NET base image should you actually use?

The right choice depends on what you're optimizing for, but the hierarchy of attack surface is clear. The full mcr.microsoft.com/dotnet/aspnet or sdk images are Debian-based and include a shell, package manager, and general-purpose userland — convenient for debugging, but every one of those tools is also available to an attacker who gets code execution inside the container. Alpine-based tags trade Debian's glibc for musl and drop most of that tooling, at the cost of occasional native-dependency compatibility issues. Chiseled Ubuntu images, GA since November 2023 and covering .NET 6 through 8, go furthest: Canonical builds them by removing every file not required at runtime, so there is no shell, no package manager, and no way to install anything after the fact even if an attacker gets a foothold. Microsoft's own case study documented a 56.6% size reduction and a 92% CVE-count reduction moving to chiseled. For most production services, chiseled should be the default; keep a full SDK image only in your build stage of a multi-stage Dockerfile, never in the final runtime layer.

Why does running .NET containers as root matter?

Before .NET 8, the official images ran as root by default, and teams had to remember to manually create and switch to a non-root user in every Dockerfile — a step that was easy to skip and rarely enforced by CI. The risk isn't abstract: a container escape vulnerability paired with a root-running process inside the container gives an attacker a direct path to host-level compromise, whereas the same escape against a non-root process is contained by the kernel's ordinary user permission checks on the host side. .NET 8's built-in app user (UID 1654) removed the excuse — a single USER $APP_UID line in the Dockerfile is now sufficient, and Microsoft's own guidance in its .NET 8 container documentation recommends it for every ASP.NET Core image going forward. The catch teams still hit: switching to a non-root user after COPY instructions that don't set ownership will leave application files owned by root, causing the app to fail at startup with permission-denied errors on its own working directory — so COPY --chown=$APP_UID:$APP_UID (or an explicit chown layer) has to be part of the same change, not an afterthought.

What's wrong with putting secrets in a Dockerfile or appsettings.json?

Baking a connection string, API key, or signing certificate into appsettings.json, a Dockerfile ENV instruction, or any other build-time layer is one of the most well-documented container anti-patterns, and it persists because it works right up until someone runs docker history against the published image. Docker images are built as a stack of layers, and a secret written in one layer remains recoverable from that layer even if a later instruction deletes the file or overwrites the environment variable — the bytes are still in the image manifest, extractable by anyone with pull access to the registry. This is distinct from, and worse than, a leaked secret in source control, because container images are routinely pushed to shared registries, scanned by third-party tools, and cached on build agents outside your primary VCS access controls. The defensive pattern is to treat secrets as a runtime concern, not a build-time one: inject them via Docker or Kubernetes secrets, a cloud secret manager (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault), or environment variables set by the orchestrator at container start. .NET's own User Secrets tooling (dotnet user-secrets) is explicitly documented as a local-development convenience only — it stores secrets unencrypted on the developer's machine and was never intended to ship inside a container image.

How do you verify a .NET image doesn't already have this problem?

Because secrets baked into earlier layers survive later RUN rm or overwrite instructions, verifying a clean image means inspecting every layer, not just the final filesystem state. docker history --no-trunc <image> shows every instruction and command string used to build each layer, which surfaces obvious mistakes like an ENV ConnectionStrings__Default=... line, but layer-diffing tools are needed to catch a secret written to a file in one RUN step and deleted in a later one — the deletion only hides the file from docker run, it doesn't remove it from the layer that's still part of the image. Safeguard's secrets scanning extracts and scans every layer of a container image, not just the final composed filesystem, using issuer-specific pattern matching for AWS, GitHub, Stripe, Slack, and OpenAI credentials among others, and verifies each match against the issuing service with a low-privilege API call so a finding is flagged as confirmed-live rather than a guess. That layer-aware, verified approach is exactly what catches the "deleted it in a later layer" mistake that a filesystem-only scan of the running container would miss entirely.

How do you keep base images patched after the image ships?

Choosing a chiseled or Alpine base solves the day-one attack surface problem, but a .NET runtime image sitting in a registry for months will still accumulate CVEs in glibc, OpenSSL, or the .NET runtime itself as new advisories are published against the pinned base digest. The manual remediation path — someone notices a CVE, opens a PR bumping the base image tag, waits for review, rebuilds, redeploys — routinely takes days, during which the vulnerable image stays in production. Safeguard's self-healing containers capability automates that loop for base-image CVEs specifically: it detects a new CVE against a tracked base image, plans a fix (upstream version bump, backport patch, or a hardened Gold-image substitution), rebuilds against the existing test suite, and promotes the patched image through a gated rollout, with median time-to-heal in the 20-to-45-minute range across observed customer tenants. For .NET images pinned to a specific chiseled Ubuntu digest, that closes the gap between "we picked a hardened base on day one" and "the base is still hardened eleven months later."

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.