Hardening .NET Containers for Production
Stop running as root. Build secure, minimal container images for .NET applications using multi-stage builds and distroless/alpine bases.
Introduction
“It runs on my machine” is not a security strategy. When deploying to Kubernetes, your container image acts as the first line of defense. A bloated image running as root is a gift to attackers.
Why Container Hardening?
- Least Privilege: If an attacker compromises the app, they shouldn’t have root access to the filesystem.
- Attack Surface: Removing shells (
/bin/sh) and package managers (apk) prevents attackers from installing tools. - Size: Smaller images pull faster and scale quicker.
What We’ll Build
- Multi-Stage Build: Separating the SDK (heavy) from the Runtime (light).
- Non-Root User: Configuring the container to run as
app(UID 1000). - Read-Only Filesystem: Preparing the app to run without write access to disk.
Architecture Overview
flowchart TB
subgraph BuildStage["🏗️ Build Stage (SDK Image)"]
Src["Source Code"] --> Restore["dotnet restore"]
Restore --> Publish["dotnet publish"]
Publish --> Artifacts["/app/publish"]
end
subgraph RuntimeStage["🚀 Runtime Stage (Alpine/Chiseled)"]
Base["Base Image"] --> User["Create User (app)"]
User --> Copy["Copy Artifacts"]
Copy --> Entry["ENTRYPOINT"]
end
Artifacts --> Copy
classDef primary fill:#7c3aed,stroke:#fff,color:#fff
classDef secondary fill:#06b6d4,stroke:#fff,color:#fff
classDef db fill:#f43f5e,stroke:#fff,color:#fff
classDef warning fill:#fbbf24,stroke:#fff,color:#fff
class RuntimeStage primary
class BuildStage secondary
Section 1: The Secure Dockerfile
Does your Dockerfile look like this?
# Dockerfile
# 1. Build Stage
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
# 2. Runtime Stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS runtime
WORKDIR /app
# 3. Create a non-root group and user
RUN addgroup -g 1000 appgroup && \
adduser -u 1000 -G appgroup -h /app -D appuser
# 4. Copy artifacts
COPY --from=build /app/publish .
# 5. Switch to non-root user
USER 1000
# 6. Expose port (must be > 1024 for non-root)
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "Archives.Api.dll"]
Section 2: Kubernetes Security Context
The Dockerfile is only half the battle. You must enforce security in your Kubernetes manifest.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: api
image: archives-api:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Conclusion
By combining a hardened Dockerfile with a restrictive Kubernetes SecurityContext, you create a “defense in depth” posture. Even if an attacker finds a Remote Code Execution (RCE) vulnerability in your API, they are trapped in a container with no root privileges, no shell, a read-only filesystem, and no capabilities.