dotnet-containers
SKILL.md
dotnet-containers
Best practices for containerizing .NET applications. Covers multi-stage Dockerfile patterns, the dotnet publish
container image feature (.NET 8+), rootless container configuration, optimized layer caching, and container health
checks.
Scope
- Multi-stage Dockerfile patterns for .NET
- SDK container publish (
dotnet publish /t:PublishContainer) - Rootless container configuration and security
- Optimized layer caching and base image selection
- Container health checks
Out of scope
- DI container mechanics and service lifetimes -- see [skill:dotnet-csharp-dependency-injection]
- Kubernetes deployment manifests and Docker Compose -- see [skill:dotnet-container-deployment]
- CI/CD pipeline integration for building and pushing images -- see [skill:dotnet-gha-publish] and [skill:dotnet-ado-publish]
- Testing containerized applications -- see [skill:dotnet-integration-testing]
Cross-references: [skill:dotnet-observability] for health check patterns, [skill:dotnet-container-deployment] for deploying containers to Kubernetes and local dev with Compose, [skill:dotnet-artifacts-output] for Dockerfile path adjustments when using centralized build output layout.
Multi-Stage Dockerfiles
Multi-stage builds separate the build environment from the runtime environment, producing minimal final images.
Standard Multi-Stage Pattern
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project files first for layer caching
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
COPY ["src/MyApi.Core/MyApi.Core.csproj", "src/MyApi.Core/"]
COPY ["Directory.Build.props", "."]
COPY ["Directory.Packages.props", "."]
RUN dotnet restore "src/MyApi/MyApi.csproj"
# Copy everything else and build
COPY . .
WORKDIR "/src/src/MyApi"
RUN dotnet publish -c Release -o /app/publish --no-restore
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
EXPOSE 8080
COPY /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
```text
### Layer Caching Strategy
Order COPY instructions from least-frequently-changed to most-frequently-changed:
1. **Project files and props** -- change only when dependencies change
2. **`dotnet restore`** -- cached until project files change
3. **Source code** -- changes with every build
4. **`dotnet publish`** -- runs only when source or restore layer changes
```dockerfile
# Good: restore layer is cached when only source changes
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
RUN dotnet restore
COPY . .
RUN dotnet publish
# Bad: restore runs on every source change
COPY . .
RUN dotnet restore
RUN dotnet publish
```text
### Solution-Level Restore
For multi-project solutions, copy all `.csproj` files and the solution file to enable a single restore:
```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy solution and all project files for restore caching
COPY ["MyApp.sln", "."]
COPY ["Directory.Build.props", "."]
COPY ["Directory.Packages.props", "."]
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
COPY ["src/MyApi.Core/MyApi.Core.csproj", "src/MyApi.Core/"]
COPY ["src/MyApi.Infrastructure/MyApi.Infrastructure.csproj", "src/MyApi.Infrastructure/"]
RUN dotnet restore
COPY . .
RUN dotnet publish "src/MyApi/MyApi.csproj" -c Release -o /app/publish --no-restore
```csharp
---
## dotnet publish Container Images (.NET 8+)
Starting with .NET 8, `dotnet publish` can produce OCI container images directly without a Dockerfile. This uses the
`Microsoft.NET.Build.Containers` SDK (included in the .NET SDK).
### Basic Usage
```bash
# Publish as a container image to local Docker daemon
dotnet publish --os linux --arch x64 /t:PublishContainer
# Publish to a remote registry
dotnet publish --os linux --arch x64 /t:PublishContainer \
-p:ContainerRegistry=ghcr.io \
-p:ContainerRepository=myorg/myapi
```text
### MSBuild Configuration
Configure container properties in the `.csproj`:
```xml
<PropertyGroup>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0</ContainerBaseImage>
<ContainerImageName>myapi</ContainerImageName>
<ContainerImageTag>$(Version)</ContainerImageTag>
</PropertyGroup>
<ItemGroup>
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>
```text
### Advanced Configuration
```xml
<PropertyGroup>
<!-- Use chiseled (distroless) base image for smaller attack surface -->
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled</ContainerBaseImage>
<!-- Run as non-root user (default for chiseled images) -->
<ContainerUser>app</ContainerUser>
</PropertyGroup>
<ItemGroup>
<!-- Environment variables -->
<ContainerEnvironmentVariable Include="ASPNETCORE_URLS" Value="http://+:8080" />
<ContainerEnvironmentVariable Include="DOTNET_RUNNING_IN_CONTAINER" Value="true" />
<!-- Labels -->
<ContainerLabel Include="org.opencontainers.image.source" Value="https://github.com/myorg/myapi" />
</ItemGroup>
```text
### When to Use dotnet publish vs Dockerfile
| Scenario | Recommendation |
| ------------------------------------------------ | ---------------------------------------------------------------- |
| Simple single-project API | `dotnet publish /t:PublishContainer` -- less boilerplate |
| Multi-stage build with native dependencies | Dockerfile -- full control over build environment |
| Need to install OS packages (e.g., `libgdiplus`) | Dockerfile -- `RUN apt-get install` not available in SDK publish |
| CI/CD with complex build steps | Dockerfile -- explicit, reproducible |
| Quick local container testing | `dotnet publish /t:PublishContainer` -- fastest iteration |
---
## Base Image Selection
### Official .NET Container Images
| Image | Use Case | Size |
| ----------------------------------------------------------- | -------------------------------------------- | ------- |
| `mcr.microsoft.com/dotnet/aspnet:10.0` | ASP.NET Core apps (Ubuntu) | ~220 MB |
| `mcr.microsoft.com/dotnet/aspnet:10.0-alpine` | ASP.NET Core apps (Alpine, smaller) | ~110 MB |
| `mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled` | Distroless (no shell, no package manager) | ~110 MB |
| `mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra` | Chiseled + globalization + time zones | ~130 MB |
| `mcr.microsoft.com/dotnet/runtime:10.0` | Console apps, worker services | ~190 MB |
| `mcr.microsoft.com/dotnet/runtime-deps:10.0` | Self-contained/AOT apps (runtime not needed) | ~30 MB |
### Choosing a Base Image
- **Default:** Use `aspnet` for web apps, `runtime` for worker services
- **Minimal footprint:** Use `chiseled` variants (no shell, no root user, no package manager)
- **Globalization needed:** Use `chiseled-extra` if your app uses culture-specific formatting or time zones
- **Self-contained or AOT:** Use `runtime-deps` -- the runtime is bundled in your app
- **Alpine:** Smaller than Ubuntu but uses musl libc; test for compatibility with native dependencies
---
## Rootless Containers
Running containers as non-root reduces the attack surface. .NET 8+ chiseled images run as non-root by default.
### Non-Root with Standard Images
```dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# Create non-root user and switch to it
RUN adduser --disabled-password --gecos "" --uid 1001 appuser
USER appuser
COPY /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
```text
### Non-Root with Chiseled Images
Chiseled images include a pre-configured `app` user (UID 1654). No additional configuration needed:
```dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
WORKDIR /app
# Already runs as non-root 'app' user (UID 1654)
COPY /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
```text
### Port Configuration
Non-root users cannot bind to ports below 1024. ASP.NET Core defaults to port 8080 in containers (set via
`ASPNETCORE_HTTP_PORTS`):
```dockerfile
# Default in .NET 8+ container images -- no explicit config needed
# ASPNETCORE_HTTP_PORTS=8080
# If you need a different port:
ENV ASPNETCORE_HTTP_PORTS=5000
EXPOSE 5000
```text
---
## Container Health Checks
Health checks allow container runtimes to monitor application readiness. The application-level health check endpoints
(see [skill:dotnet-observability]) are consumed by Docker and Kubernetes probes.
### Docker HEALTHCHECK
```dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# Health check using curl (not available in chiseled images)
HEALTHCHECK \
CMD curl -f http://localhost:8080/health/live || exit 1
COPY /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
```text
For chiseled images (no `curl`), use a dedicated health check binary or rely on orchestrator-level probes (Kubernetes
`httpGet`, Docker Compose `test`):
```dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
WORKDIR /app
# No HEALTHCHECK directive -- use orchestrator probes instead
# See [skill:dotnet-container-deployment] for Kubernetes probe configuration
COPY /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
```text
### Health Check Endpoints
Register health check endpoints in your application (see [skill:dotnet-observability] for full guidance):
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddNpgSql(
builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "database",
tags: ["ready"]);
var app = builder.Build();
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
```text
---
## Container Optimization
### .dockerignore
Always include a `.dockerignore` to exclude unnecessary files from the build context:
```text
**/.git
**/.vs
**/.vscode
**/bin
**/obj
**/node_modules
**/*.user
**/*.suo
**/Dockerfile*
**/docker-compose*
**/.dockerignore
**/README.md
**/LICENSE
```markdown
### Globalization and Time Zones
If your app needs globalization support (culture-specific formatting, time zones), configure ICU:
```dockerfile
# Option 1: Use the chiseled-extra image (includes ICU + tzdata)
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra
# Option 2: Disable globalization for smaller images (if not needed)
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true
```text
### Memory Limits
Configure .NET to respect container memory limits:
```dockerfile
# .NET automatically detects container memory limits and adjusts GC heap size.
# Override only if needed:
ENV DOTNET_GCHeapHardLimit=0x10000000 # 256 MB hard limit
```dockerfile
.NET automatically reads cgroup memory limits. The GC adjusts its heap size to stay within the container memory budget.
Avoid setting `DOTNET_GCHeapHardLimit` unless you have a specific reason.
### ReadOnlyRootFilesystem
For defense-in-depth, run with a read-only root filesystem. Ensure writable paths for temp files:
```dockerfile
ENV DOTNET_EnableDiagnostics=0
# Or mount a tmpfs at /tmp for diagnostics support
```dockerfile
---
## Key Principles
- **Use multi-stage builds** -- keep build tools out of the final image
- **Order COPY for layer caching** -- project files and restore before source code
- **Prefer chiseled images** for production -- no shell, no root, minimal attack surface
- **Use `dotnet publish /t:PublishContainer`** for simple projects -- skip Dockerfile boilerplate
- **Run as non-root** -- use `USER` directive or chiseled images (non-root by default)
- **Set health check endpoints** -- enable orchestrators to monitor application state (see [skill:dotnet-observability])
- **Include `.dockerignore`** -- keep build context small and exclude secrets
---
## Agent Gotchas
1. **Do not use `mcr.microsoft.com/dotnet/sdk` as the final image** -- SDK images are 800+ MB and include build tools.
Always use `aspnet`, `runtime`, or `runtime-deps` for the final stage.
2. **Do not hardcode image tags to a patch version** (e.g., `10.0.1`) -- use `10.0` to receive security patches. Pin to
patch versions only if you have a specific compatibility requirement.
3. **Do not use `HEALTHCHECK` with chiseled images** -- chiseled images have no `curl` or shell. Use orchestrator-level
probes (Kubernetes `httpGet`, Docker Compose `test`) instead.
4. **Do not forget `--no-restore` on `dotnet publish` after a separate `dotnet restore` step** -- without it, restore
runs again and breaks layer caching.
5. **Do not bind to ports below 1024 in non-root containers** -- .NET defaults to port 8080 in container images. If you
override `ASPNETCORE_HTTP_PORTS`, ensure the port is >= 1024.
6. **Do not omit `.dockerignore`** -- without it, the build context includes `.git`, `bin/obj`, and potentially secrets,
increasing build time and image size.
---
## References
- [.NET container images](https://learn.microsoft.com/en-us/dotnet/core/docker/build-container)
- [Containerize a .NET app with dotnet publish](https://learn.microsoft.com/en-us/dotnet/core/docker/publish-as-container)
- [.NET container image variants](https://learn.microsoft.com/en-us/dotnet/core/docker/container-images)
- [Chiseled Ubuntu containers for .NET](https://devblogs.microsoft.com/dotnet/dotnet-6-is-now-in-ubuntu-2204/#chiseled-ubuntu-containers)
- [ASP.NET Core health checks](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks)
- [Docker multi-stage builds](https://docs.docker.com/build/building/multi-stage/)
Weekly Installs
1
Repository
rudironsoni/dot…s-pluginFirst Seen
11 days ago
Security Audits
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1