Docker Multi-Stage Builds and Chiseled Containers in .NET: From 450MB to 48MB Production Images
Shrink .NET Docker container images from 450MB to 48MB using multi-stage builds and Ubuntu Chiseled containers. Eliminate CVEs and master non-root security.

Author & Creator · CodeToClarity
High-throughput cloud applications place stringent demands on container deployment speed, infrastructure costs, and attack surface security. When an engineering team deploys an ASP.NET Core microservice into a production Kubernetes cluster managed by a Horizontal Pod Autoscaler (HPA), cold startup latency directly influences whether the platform absorbs a traffic surge or drops client requests. Pulling heavy container images across cluster worker nodes consumes valuable network bandwidth and adds tens of seconds to pod initialization times.
Beyond deployment latency, container base images introduce serious security liabilities. Default enterprise base images bundle hundreds of megabytes of unused operating system utilities, including package managers like apt or dpkg, shell binaries like bash and sh, and diagnostic utilities. Vulnerability scanners such as Trivy, Snyk, and Prisma Cloud flag dozens of Common Vulnerabilities and Exposures (CVE) inside these dormant utilities, forcing security teams into endless patch cycles. By combining Docker multi-stage builds, non-root execution privileges, and Canonical Ubuntu Chiseled containers in .NET 8 and .NET 9, teams can shrink production container images from 450MB down to 48MB while eliminating package managers and shells entirely.
Key Takeaways
- Radical Footprint Reduction: Multi-stage builds paired with Ubuntu Chiseled base images reduce ASP.NET Core image sizes from 450MB to 48MB, dropping Kubernetes cold-pull times from 14.8 seconds to 1.9 seconds.
- Zero-CVE Attack Surface: Ubuntu Chiseled removes all shells (
/bin/sh,/bin/bash) and package managers (apt,dpkg), neutralizing common reverse-shell payloads and container breakout vectors.- Native Non-Root Security: Modern .NET container images execute under the pre-configured
appuser (UID 1654) on port 8080 by default, satisfying Kubernetes Restricted Pod Security Standards without custom user configuration.- Standard C Library Parity: Unlike Alpine Linux which relies on
musllibc, Ubuntu Chiseled uses standardglibc, avoiding subtle memory allocation delays, thread stack discrepancies, and international globalization quirks.- Operational Boundary: Appliance-style chiseled containers prohibit interactive shell debugging and shell-based entrypoint migration scripts, requiring dedicated Kubernetes init containers and ephemeral debug workflows.
1. The Container Bloat Dilemma: Image Weight, Autoscaling Delays, and Security CVEs
Container bloat refers to the accumulation of unnecessary operating system utilities, compiler toolchains, package managers, and development dependencies within a runtime container image, inflating file sizes and expanding the security attack surface. In microservice architectures, this excess baggage degrades operational agility and introduces preventable vulnerabilities into production clusters.
When developers write an initial Dockerfile for an ASP.NET Core API, they often rely on a single base image or copy excessive build-time artifacts into their final runtime environment. The official .NET Software Development Kit (SDK) image (mcr.microsoft.com/dotnet/sdk:9.0) exceeds 880MB uncompressed because it includes the Roslyn C# compiler, MSBuild engines, NuGet client packages, development headers, and command-line tools. Running an API service directly inside an SDK image means every container carries a massive compile environment into production.
Even when developers switch to the standard runtime image (mcr.microsoft.com/dotnet/aspnet:9.0), the resulting container image frequently sits between 350MB and 450MB. This occurs because standard runtime images are built on general-purpose Linux distributions such as Debian Bookworm or full Ubuntu Server. These general-purpose distributions include system libraries, terminal utilities, shell interpreters, and the Debian package management toolchain.
Traditional Single-Stage Container Footprint:
┌────────────────────────────────────────────────────────┐
│ Full Operating System Userland (Debian / Ubuntu) │ ~180 MB
│ Package Management: apt-get, dpkg, debconf │ ~40 MB
│ Shell Interpreters: /bin/bash, /bin/sh, /bin/dash │ ~15 MB
│ Core Utilities: coreutils, curl, wget, tar, grep │ ~35 MB
│ ASP.NET Core Runtime and Base Class Libraries (BCL) │ ~150 MB
│ Application DLLs, Configuration, and Dependencies │ ~30 MB
└────────────────────────────────────────────────────────┘
Total Image Size: ~450 MB | CVE Alerts in Trivy: 142
This structural bloat causes severe consequences across production environments:
- Slow Kubernetes Autoscaling: When traffic spikes hit a service behind load balancers and reverse proxies, the Kubernetes Horizontal Pod Autoscaler schedules new pods across cluster nodes. If a worker node has not yet cached the 450MB image, it must pull layers across the internal container registry. Downloading and decompressing 450MB over a saturated cluster network takes between 12 and 20 seconds. During this window, incoming client requests queue up, leading to timeouts and degraded service availability.
- Elevated Registry and Network Ingress Costs: In multi-region cloud environments, pulling hundreds of large image layers across cloud availability zones incurs steady network transfer fees and consumes gigabytes of cache disk space on every worker node.
- Severe Vulnerability Exposure (CVE Noise): Container security scanners inspect every package recorded in the container package database. An idle utility like
greporcoreutilsthat your ASP.NET Core API never calls will trigger high-priority security notifications when a new CVE is published. Security engineers spend hours auditing packages that should never have existed in the container in the first place. - Privilege Escalation and Reverse Shell Vectors: If an application vulnerability like remote code execution or path traversal is discovered in your API, an attacker who gains execution privileges inside the container immediately looks for
/bin/shor/bin/bashto launch an interactive reverse shell. Ifaptorcurlis present, the attacker can download secondary exploitation tooling directly to memory.
Meeting the reliability targets outlined in our guide on 8 Essential Non-Functional Requirements requires an architectural shift: completely decoupling compile tools from runtime environments, stripping all unnecessary binaries, and executing under strict non-root privileges.
2. The Architecture of Ubuntu Chiseled Containers: How Canonical Slices the Operating System
Ubuntu Chiseled containers are ultra-minimal, appliance-like container images engineered by Canonical that contain only the exact shared libraries and runtime binaries required to execute an application, omitting package managers, shell interpreters, and user utilities. Built using package slice definitions, chiseled images deliver the security properties of distroless containers with the stability of Long Term Support (LTS) Ubuntu.
Historically, container developers seeking minimal footprints turned to Google Container Tools distroless images or Alpine Linux. While Alpine Linux achieves an impressively small uncompressed size (~140MB with ASP.NET Core), it relies on the musl standard C library rather than the glibc implementation used by mainstream Linux distributions.
The architectural choice between glibc and musl carries tangible production ramifications for .NET applications:
- Memory Allocator Differences: The
muslmemory allocator handles multi-threaded allocation arenas differently thanglibc. Under heavy concurrent workloads, high-allocation .NET services on Alpine can experience thread contention and allocation latency spikes. - Globalization and Culture Invariant Mode: Alpine does not bundle full International Components for Unicode (ICU) globalization datasets by default. Unless developers explicitly install the
icu-libspackage (which adds 35MB back to the image) or configureDOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true, string comparisons, date formatting, and culture-sensitive operations can throw runtime exceptions. - Third-Party Native Interop: Native C and C++ dynamic libraries compiled against
glibccannot link againstmuslwithout specialized Alpine rebuilds, complicating the use of proprietary hardware drivers, cryptographic modules, or image processing libraries.
Canonical resolved this dilemma by introducing Chiseled Ubuntu. Rather than constructing a stripped-down Linux distribution from scratch, Canonical created an open-source tool called chisel. The chisel tool reads declarative YAML slice definitions for standard Ubuntu packages (such as libc6, libssl3, and ca-certificates). It extracts only the individual shared object files (.so), configuration files, and copyright notices necessary for runtime execution, discarding man pages, header files, documentation, package management databases, and administrative binaries.
Microsoft partnered closely with Canonical to deliver officially supported chiseled container base images for .NET 8 and .NET 9. These images are published on the Microsoft Container Registry (mcr.microsoft.com) and are built upon Ubuntu 24.04 LTS (Noble Numbat).
The following comparison illustrates how different base image topologies balance footprint, tooling, and attack surface:
| Architectural Metric | Debian 12 Bookworm (aspnet:9.0) | Alpine Linux 3.20 (aspnet:9.0-alpine) | Ubuntu Noble Chiseled (aspnet:9.0-noble-chiseled) | Native AOT Chiseled (runtime-deps:9.0-noble-chiseled) |
|---|---|---|---|---|
| Uncompressed Image Size | ~420 MB | ~140 MB | ~48 MB | ~16 MB |
| Standard C Library | glibc 2.36 | musl libc | glibc 2.39 (Standard) | glibc 2.39 (Standard) |
| Shell Interpreters | /bin/bash, /bin/sh | /bin/ash, /bin/sh | None (Zero Shells) | None (Zero Shells) |
| Package Management | apt-get, dpkg | apk | None (Zero Package Managers) | None (Zero Package Managers) |
| Default User Profile | root (UID 0) | app (UID 1654) | app (UID 1654) | app (UID 1654) |
| Default HTTP Port | 8080 | 8080 | 8080 | 8080 |
| ICU Globalization | Full ICU Included | Invariant (or extra package) | Full ICU in chiseled-extra | Culture Invariant (AOT default) |
| Known Scanner CVEs | 142 Vulnerabilities | 12 Vulnerabilities | 0 Vulnerabilities | 0 Vulnerabilities |
| Kubernetes Cold Pull | ~14.8 seconds | ~4.2 seconds | ~1.9 seconds | ~0.6 seconds |
By adopting aspnet:9.0-noble-chiseled, your microservices gain the full performance and compatibility benefits of standard glibc while shedding 370MB of unused operating system baggage.
3. Designing an Optimized Multi-Stage Dockerfile with Layer Caching
A Docker multi-stage build is a container authoring technique that uses multiple FROM instructions within a single Dockerfile to separate the compilation environment from the final execution environment, copying only the resulting runtime binaries into the final image. This process ensures that compiler tools, intermediate project files, and secret tokens remain isolated from the production artifact.
To maximize build speed on Continuous Integration (CI) runners, a multi-stage Dockerfile must also structure its directives to exploit Docker layer caching. Docker caches each layer based on the hash of the instruction and the files copied. Placing instructions that change infrequently (such as dependency restoration) ahead of instructions that change on every commit (such as application source code) eliminates redundant package downloads.
Here is a production-grade multi-stage Dockerfile designed for an ASP.NET Core Web API project named CodeToClarity.Api:
# syntax=docker/dockerfile:1.7
# ==============================================================================
# Stage 1: Build Environment (Compile, Restore, Publish)
# ==============================================================================
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Step 1: Copy only project solution and project files for layer caching
COPY ["CodeToClarity.Api/CodeToClarity.Api.csproj", "CodeToClarity.Api/"]
COPY ["CodeToClarity.Core/CodeToClarity.Core.csproj", "CodeToClarity.Core/"]
# Step 2: Restore NuGet dependencies as an isolated layer
RUN dotnet restore "CodeToClarity.Api/CodeToClarity.Api.csproj" \
--runtime linux-x64
# Step 3: Copy the complete source code tree
COPY . .
# Step 4: Publish compiled release binaries to /app/publish
WORKDIR "/src/CodeToClarity.Api"
RUN dotnet publish "CodeToClarity.Api.csproj" \
-c Release \
-o /app/publish \
--no-restore \
--runtime linux-x64 \
--self-contained false \
/p:UseAppHost=false
# ==============================================================================
# Stage 2: Production Hardened Runtime (Ubuntu Noble Chiseled)
# ==============================================================================
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final
WORKDIR /app
# Step 5: Enforce non-root execution using the built-in non-root app user
USER $APP_UID
# Step 6: Expose and bind to the standard non-privileged HTTP port
EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DOTNET_EnableDiagnostics=0
# Step 7: Copy compiled application binaries from the build stage
COPY --from=build --chown=$APP_UID:$APP_UID /app/publish .
# Step 8: Configure direct binary execution without a shell wrapper
ENTRYPOINT ["dotnet", "CodeToClarity.Api.dll"]
Examining each stage highlights several critical engineering choices:
- Isolated Dependency Restoration: Step 1 copies only the
.csprojfiles, followed immediately bydotnet restorein Step 2. When an engineer modifies business logic in a.cscontroller file and pushes a commit, Docker recognizes that the.csprojfiles have not changed. The builder reuses the cached layer from Step 2, bypassing NuGet network downloads completely. Build times drop from 45 seconds to under 4 seconds on local machines and CI agents. - The
--self-contained falseand/p:UseAppHost=falseFlags: Because the final stage usesaspnet:9.0-noble-chiseled, the ASP.NET Core shared framework is already present in the runtime image. Disabling self-contained output prevents bundling another copy of the .NET runtime DLLs into/app/publish. SettingUseAppHost=falseprevents generating the platform-specific native executable launcher (CodeToClarity.Api), letting thedotnetCLI command execute the managed assembly directly. - The
--chown=$APP_UID:$APP_UIDFlag: When copying files from the build stage into the final stage, files default to root ownership unless explicitly overridden. Specifying--chown=$APP_UID:$APP_UIDguarantees that the non-rootappuser possesses read permissions on all DLLs and static assets without requiring runtimechmodcommands (which cannot run inside a chiseled container because no shell exists).
4. Enforcing Non-Root Execution and Port 8080 Networking
Running container processes as the root user violates the principle of least privilege and represents one of the most common misconfigurations in containerized environments. If a container running as root suffers a container breakout vulnerability, the process escapes directly into the host operating system with full kernel administrator privileges.
Starting in .NET 8 and continued in .NET 9, Microsoft introduced native support for non-root execution across all official container images:
- The Pre-Configured
appUser: Every .NET Linux base image includes a built-in user namedappassigned to User Identifier (UID)1654and Group Identifier (GID)1654. - The
$APP_UIDEnvironment Variable: Dockerfiles can reference the environment variableUSER $APP_UIDdirectly without hardcoding numerical IDs. - The Non-Privileged Port Shift: On Linux operating systems, binding to network ports numbered below 1024 (such as port 80 or port 443) requires the
CAP_NET_BIND_SERVICEkernel capability, which standard non-root users lack. To enable seamless non-root operation out of the box, .NET 8 and .NET 9 changed the default Kestrel binding port from port 80 to port8080via theASPNETCORE_HTTP_PORTS=8080environment variable.
The following minimal ASP.NET Core service demonstrates proper port binding, health check configuration, and integration with the request pipeline:
namespace CodeToClarity.Api;
using System.Text.Json.Serialization;
public static class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Configure JSON serialization for high-performance minimal APIs
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
});
// Register standard health checks for Kubernetes probes
builder.Services.AddHealthChecks();
var app = builder.Build();
// Map health check endpoint for Kubernetes liveness and readiness probes
app.MapHealthChecks("/healthz");
// Production API endpoint following clean architectural principles
app.MapGet("/api/v1/system-info", () =>
{
return Results.Ok(new SystemStatusResponse(
Status: "Healthy",
Runtime: Environment.Version.ToString(),
IsPrivilegedUser: Environment.UserName == "root",
ProcessId: Environment.ProcessId,
CurrentTimeUtc: DateTimeOffset.UtcNow
));
});
app.Run();
}
}
public sealed record SystemStatusResponse(
string Status,
string Runtime,
bool IsPrivilegedUser,
int ProcessId,
DateTimeOffset CurrentTimeUtc
);
When building scalable APIs with this architecture, ensure your endpoint routing and versioning adhere to the standards outlined in our Production API Design Guide. Furthermore, if your application processes cross-cutting concerns like logging and exception transformation, consult our guide on Middleware in ASP.NET Core: Beginner's Complete Guide.
To verify non-root compliance inside a Kubernetes manifest, configure your pod securityContext to enforce immutable non-root execution:
apiVersion: apps/v1
kind: Deployment
metadata:
name: codetoclarity-api
namespace: production
labels:
app: codetoclarity-api
spec:
replicas: 3
selector:
matchLabels:
app: codetoclarity-api
template:
metadata:
labels:
app: codetoclarity-api
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
fsGroup: 1654
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: registry.codetoclarity.in/api:chiseled-v1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 3
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 2
periodSeconds: 5
Notice the setting readOnlyRootFilesystem: true. Because chiseled images do not require temporary file writing in /tmp or system logs in /var/log, the entire container filesystem can be locked down as read-only. This setting prevents an attacker from writing malicious payloads or modifying existing binaries even if an application vulnerability exists.
5. Pushing Limits: Native AOT Deployment with Chiseled Runtime-Deps (16MB Images)
Ahead-Of-Time (AOT) compilation compiles C# code directly into architecture-specific machine code during the build stage rather than compiling to Common Intermediate Language (CIL) that requires Just-In-Time (JIT) compilation at runtime. In .NET 8 and .NET 9, Native AOT eliminates the need for the ASP.NET Core runtime engine and the JIT compiler inside the container.
Because the published artifact is a self-contained native binary, the runtime container does not need the aspnet base image. It only requires the low-level operating system dependencies needed by native Linux binaries: glibc, libssl, zlib, and cryptographic certificates. Microsoft provides this ultra-minimal base image under the tag mcr.microsoft.com/dotnet/nightly/runtime-deps:9.0-noble-chiseled (or the stable runtime-deps:9.0-noble-chiseled).
The resulting container image weighs an astonishing 16MB, starts in less than 15 milliseconds, and consumes less than 20MB of memory on startup.
To enable Native AOT in your project file (CodeToClarity.Api.csproj), add the PublishAot property:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
<StripSymbols>true</StripSymbols>
<OptimizationPreference>Size</OptimizationPreference>
</PropertyGroup>
</Project>
Next, author a multi-stage Dockerfile that installs the native C compiler toolchain in the build stage and packages the binary into the runtime-deps chiseled base:
# syntax=docker/dockerfile:1.7
# ==============================================================================
# Stage 1: Build Environment with Native Compiler Toolchain
# ==============================================================================
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Step 1: Install native Linux compilation dependencies required by Native AOT
RUN apt-get update && apt-get install -y --no-install-recommends \
clang \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Step 2: Copy project file and restore packages
COPY ["CodeToClarity.Api/CodeToClarity.Api.csproj", "CodeToClarity.Api/"]
RUN dotnet restore "CodeToClarity.Api/CodeToClarity.Api.csproj" \
--runtime linux-x64
# Step 3: Copy source and publish native AOT binary
COPY . .
WORKDIR "/src/CodeToClarity.Api"
RUN dotnet publish "CodeToClarity.Api.csproj" \
-c Release \
-r linux-x64 \
-o /app/publish \
--no-restore
# ==============================================================================
# Stage 2: Ultra-Minimal Chiseled Native Appliance
# ==============================================================================
FROM mcr.microsoft.com/dotnet/runtime-deps:9.0-noble-chiseled AS final
WORKDIR /app
# Step 4: Run as non-root user
USER $APP_UID
# Step 5: Configure port and production environment
EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080
ENV DOTNET_EnableDiagnostics=0
# Step 6: Copy the single native compiled binary
COPY --from=build --chown=$APP_UID:$APP_UID /app/publish/CodeToClarity.Api .
# Step 7: Execute native binary directly
ENTRYPOINT ["./CodeToClarity.Api"]
When pairing Native AOT with caching abstractions, ensure your serializers do not rely on runtime reflection. For high-speed caching architectures compatible with Native AOT, explore our deep dive on HybridCache in .NET 9.
The operational trade-off of Native AOT is strict compile-time trimming. Code patterns that depend on unbounded reflection, dynamic assembly loading, or unannotated JSON serializers will fail to compile or throw runtime exceptions. For standardized microservices using Minimal APIs, EF Core, and System.Text.Json source generators, Native AOT paired with Chiseled containers delivers unparalleled startup performance.
6. Scanning, Benchmarking, and Verifying the Production Image
Container image verification requires concrete validation using vulnerability scanners, layer inspection tools, and execution testing before promoting an image to a production registry. Relying on assumptions without empirical proof invites unforeseen failures during cluster deployments.
To evaluate security improvements, scan both the traditional Debian-based image and the Ubuntu Chiseled image using the open-source scanner Trivy:
# Scan the standard Debian-based ASP.NET Core image
trivy image --severity HIGH,CRITICAL codetoclarity-api:debian-standard
# Scan the Ubuntu Chiseled ASP.NET Core image
trivy image --severity HIGH,CRITICAL codetoclarity-api:chiseled
The difference in scan output is striking:
Scan Report for codetoclarity-api:debian-standard (debian 12.5)
=============================================================
Total Vulnerabilities: 142 (UNKNOWN: 0, LOW: 78, MEDIUM: 23, HIGH: 33, CRITICAL: 8)
┌────────────────┬────────────────┬──────────┬───────────────────┬───────────────┐
│ Package │ Vulnerability │ Severity │ Installed Version │ Fixed Version │
├────────────────┼────────────────┼──────────┼───────────────────┼───────────────┤
│ coreutils │ CVE-2024-XXXXX │ HIGH │ 9.1-1 │ 9.1-2 │
│ libgnutls30 │ CVE-2024-YYYYY │ CRITICAL │ 3.7.9-2 │ 3.7.9-2+deb12 │
│ tar │ CVE-2023-ZZZZZ │ HIGH │ 1.34+dfsg-1.2 │ 1.34+dfsg-1.3 │
│ perl-base │ CVE-2023-AAAAA │ HIGH │ 5.36.0-7 │ 5.36.0-7+deb1 │
└────────────────┴────────────────┴──────────┴───────────────────┴───────────────┘
Scan Report for codetoclarity-api:chiseled (ubuntu 24.04)
========================================================
Total Vulnerabilities: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Because Ubuntu Chiseled contains zero operating system user utilities, all 142 vulnerabilities originating from unused packages disappear completely.
Next, inspect layer composition and disk utilization using docker history:
docker history codetoclarity-api:chiseled \
--format "table {{.Size}}\t{{.CreatedBy}}"
SIZE CREATED BY
24.1MB COPY --from=build --chown=$APP_UID:$APP_UID /app/publish .
0B ENV ASPNETCORE_HTTP_PORTS=8080 ASPNETCORE_ENVIRONMENT=Production
0B EXPOSE 8080/tcp
0B USER 1654
0B WORKDIR /app
23.8MB Chisel slice: aspnet-runtime-9.0, glibc, libssl3, ca-certificates
The entire container consists of two functional layers: a 23.8MB base layer containing the chiseled runtime and your 24.1MB application binary layer, yielding a total uncompressed image size of 47.9MB.
Finally, verify that shell execution is strictly blocked by attempting to run an interactive shell inside the running container:
docker run -d --name test-chiseled -p 8080:8080 codetoclarity-api:chiseled
# Attempt to execute an interactive shell
docker exec -it test-chiseled /bin/sh
Docker immediately returns an execution failure:
OCI runtime exec failed: exec failed: unable to start container process:
exec: "/bin/sh": stat /bin/sh: no such file or directory: unknown
The shell binary does not exist on the filesystem. An attacker attempting to inject a command string or establish a terminal connection encounters an immediate operating system fault.
7. Operational Trade-offs: When NOT to Use Chiseled Images
While Ubuntu Chiseled containers provide exceptional security and size advantages, appliance-style architectures introduce distinct operational constraints. Adopting chiseled containers requires evaluating how your development and operations workflows interact with production containers.
The following scenarios represent situations where standard chiseled images require architectural workarounds or should not be used:
1. Shell-Based Entrypoint and Pre-Launch Migration Scripts
Many legacy container setups execute a bash script as their container entrypoint (ENTRYPOINT ["/entrypoint.sh"]). These scripts typically run database migrations, perform curl health checks against external vaults, or substitute environment variables into static files before launching the .NET process.
Because chiseled containers do not contain /bin/sh or /bin/bash, any Dockerfile declaring a shell script entrypoint will immediately crash on launch with an exec: not found error.
Architectural Solution: Move operational tasks outside the application container. In Kubernetes, execute database schema migrations using a dedicated Kubernetes Job or an initContainer equipped with standard tooling. Alternatively, execute migrations programmatically inside C# code during application startup before invoking app.Run().
2. Live In-Container Troubleshooting and Interactive Debugging
During complex production incidents, developers occasionally use docker exec -it <container> /bin/bash to inspect running processes, curl local endpoints, inspect file permissions, or view environment variables.
In a chiseled container, docker exec cannot launch an interactive terminal because no command interpreter exists. Tools like curl, netstat, ps, and cat are absent.
Architectural Solution: Do not rely on invasive terminal debugging inside production containers. Instead, implement comprehensive structured logging and OpenTelemetry metrics exported to centralized dashboards. If you must inspect a live Kubernetes pod, use the native kubectl debug command to attach an ephemeral debugging sidecar container sharing the target pod network and process namespace:
# Attach an ephemeral debug container to a running chiseled pod
kubectl debug -it pod/codetoclarity-api-7b94c8-x9z2 \
--image=nicolaka/netshoot \
--target=api
The debugging container shares the network interface and process tree of the chiseled pod, allowing complete diagnostic access without compromising the security posture of the production image.
3. Dependencies on Native C/C++ System Packages
Certain enterprise applications depend on third-party native libraries that must be installed through package managers. Examples include:
- Graphics rendering libraries that depend on
libgdiplusorfontconfig. - Computer vision workflows depending on OpenCV system packages.
- Specialized enterprise database drivers requiring proprietary ODBC or UnixODBC managers.
Because chiseled containers do not contain apt or dpkg, you cannot run apt-get install inside the runtime stage.
Architectural Solution: If your application requires specific native libraries, you have two options. You can use Canonical's chisel CLI during your Docker build process to craft a custom chiseled slice containing your specific native dependencies. Alternatively, fall back to the standard Debian-based runtime image (mcr.microsoft.com/dotnet/aspnet:9.0) and apply non-root user constraints.
4. Full Culture and Globalization Dataset Requirements
The standard aspnet:9.0-noble-chiseled image contains a trimmed globalization dataset to maintain its sub-50MB size. If your application handles international currency formatting, multi-language collation, or complex calendar systems across dozens of locales, the default chiseled image may fall back to invariant culture behavior.
Architectural Solution: Microsoft publishes a specialized chiseled image variant containing the complete ICU dataset:
# Use the chiseled-extra image when full internationalization is required
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled-extra AS final
The chiseled-extra image adds approximately 35MB of globalization data, bringing the total image size to roughly 83MB, while retaining the zero-shell, zero-package-manager security posture.
8. Migration Blueprint: Step-by-Step Transition Checklist
Transitioning an existing ASP.NET Core microservice fleet from traditional bloated images to Ubuntu Chiseled containers should follow a disciplined verification process.
Execute this migration checklist to guarantee smooth production cutovers:
- Audit Startup Logic for Shell Scripts: Verify that your application entrypoint executes
dotnet YourApp.dlldirectly rather than executing a.shshell wrapper. Move any pre-launch orchestration into C# startup code or Kubernetes init containers. - Verify Port Configurations: Confirm that all internal networking configurations, Kubernetes service definitions, and Helm charts point to target port
8080instead of port80. Set the environment variableASPNETCORE_HTTP_PORTS=8080in your Dockerfile. - Adopt Non-Root Ownership in Multi-Stage Copies: Update the copy command in your runtime stage to
COPY --from=build --chown=$APP_UID:$APP_UID /app/publish .to guarantee appropriate file permissions. - Test Globalization Sensitivity: If your application performs culture-specific parsing or date transformations, run integration tests against
aspnet:9.0-noble-chiseled. If culture formatting discrepancies appear, switch the base image toaspnet:9.0-noble-chiseled-extra. - Enforce Read-Only Filesystem in Kubernetes: Validate that your application does not write temporary files to the local container disk. If your service requires temporary disk storage for file uploads, mount an ephemeral Kubernetes
emptyDirvolume at/tmp. - Integrate Trivy Vulnerability Gates into CI/CD: Add automated container scanning into your GitHub Actions or Azure DevOps build pipelines to fail builds if any high or critical CVE vulnerabilities appear in container images.
- Conduct Cold-Start Scaling Verification: Run stress tests against your Kubernetes Horizontal Pod Autoscaler to observe pod scale-out latency. Record cold pull times and resource utilization across worker nodes to benchmark the performance improvements achieved by your new 48MB appliance containers.

Software engineer passionate about building scalable, production-grade applications with C#, .NET, and modern cloud technologies. Writes regularly on CodeToClarity to demystify complex engineering architectures and help developers grow.
Related Technical Guides
What Is Docker? A Beginner-Friendly Guide with Real CI/CD Examples
Struggling to understand Docker? Learn what Docker is, how containers work, and how Docker fits into CI/CD pipelines with simple real-world examples.
Migrating to .slnx Format in .NET: The Complete Guide to Modern Solution Files
Learn why the classic .sln file is outdated and how migrating to the modern XML-based .slnx format in .NET eliminates merge conflicts and simplifies CI/CD.
The Ultimate Guide to .NET Observability: Traces, Logs, and Metrics
Stop guessing why your .NET app is failing. Learn how to implement true observability using OpenTelemetry and Grafana Cloud to track traces, metrics, and logs.