Production-Safe Dockerfile for ASP.NET Core: Multi-Stage Builds, Non-Root User & Slim Images

The Dockerfile Every ASP.NET Core Project Needs in Production

Most ASP.NET Core Dockerfiles you find in tutorials work. They build, they run, they ship an image. They are also frequently 700MB, running as root, containing the full .NET SDK, and copying your local bin/ folder — or worse, your .env secrets file — directly into the image layers. A Dockerfile that works in development and a Dockerfile that is safe for production are not the same file.

This article gives you the single most reusable artefact in any ASP.NET Core containerisation workflow: a production-safe Dockerfile, built correctly from the start. Multi-stage builds that keep the SDK out of your runtime image. A slim base image that cuts your image size by 60% or more. A non-root user that removes the most common container attack surface. A .dockerignore that keeps your secrets, build noise, and Git history out of every layer. Each decision is explained so your team can maintain it, not just copy it.

What a Naive Dockerfile Gets Wrong

Before the fix, the problem. A single-stage Dockerfile is the natural first attempt — one base image, restore, build, publish, run. It produces a working container. It also ships 600MB of compiler toolchain, NuGet cache, and intermediate build artefacts into every environment your image touches. The SDK image is not a runtime image. Treating it as one is the root cause of bloated, insecure ASP.NET Core containers.

Dockerfile — What Most Teams Start With (And Why It's Wrong)
# ── NAIVE SINGLE-STAGE DOCKERFILE ────────────────────────────────────────
# This works. It is not production-safe.

FROM mcr.microsoft.com/dotnet/sdk:8.0
# Problem 1: SDK image is ~750MB — compilers, MSBuild, NuGet cache included.
# None of that is needed at runtime. It will sit inside every image you ship.

WORKDIR /app

COPY . .
# Problem 2: Copies everything — bin/, obj/, .git/, .env, *.user files,
# local secrets, IDE artefacts. No .dockerignore = no protection.

RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish

EXPOSE 8080
ENTRYPOINT ["dotnet", "/app/publish/MyApp.dll"]
# Problem 3: Runs as root (uid 0). If an attacker exploits your app,
# they have root inside the container.

# Result: ~800MB image, running as root, SDK present, local secrets potentially
# baked into layers. Passes docker run. Fails every production security review.

The three problems compound each other. A large image takes longer to pull in CI and on cold-start in your orchestrator — which directly affects deployment latency and autoscaling response time. Running as root means a compromised container is a root shell. Copying without a .dockerignore means any file present in your working directory at build time is a candidate for leaking into the image build context — including files you never explicitly COPY, because the entire context is sent to the Docker daemon before any instruction runs. The fix addresses all three with a single, well-structured Dockerfile.

The Production-Safe Dockerfile, Annotated

The correct pattern separates the build environment from the runtime environment using Docker's multi-stage build feature. Each FROM instruction starts a new stage. Earlier stages are discarded from the final image — only what you explicitly COPY --from= carries forward. The SDK never enters the runtime image. The runtime image is slim, locked to a specific version, and runs as an unprivileged user.

Dockerfile — Production-Safe Multi-Stage Build for ASP.NET Core
# ════════════════════════════════════════════════════════════════════════════
# STAGE 1: restore
# Separate restore from build to maximise layer cache reuse.
# The restore layer is invalidated only when .csproj or NuGet.config changes —
# not every time application source code changes.
# ════════════════════════════════════════════════════════════════════════════
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore

WORKDIR /src

# Copy project files first — layer cache is invalidated only on .csproj change
COPY ["src/MyApp/MyApp.csproj",         "src/MyApp/"]
COPY ["src/MyApp.Core/MyApp.Core.csproj","src/MyApp.Core/"]
# Add additional .csproj paths here for each project in your solution

RUN dotnet restore "src/MyApp/MyApp.csproj"


# ════════════════════════════════════════════════════════════════════════════
# STAGE 2: build
# Full source copy + publish. Inherits the restored NuGet cache from stage 1.
# ════════════════════════════════════════════════════════════════════════════
FROM restore AS build

COPY . .

RUN dotnet publish "src/MyApp/MyApp.csproj" \
      --configuration Release \
      --no-restore \
      --output /app/publish \
      /p:UseAppHost=false
# --no-restore: NuGet packages already restored in stage 1
# /p:UseAppHost=false: suppresses the native executable wrapper —
#   not needed when the ENTRYPOINT is `dotnet `


# ════════════════════════════════════════════════════════════════════════════
# STAGE 3: runtime (final image)
# Only this stage ships. The SDK, NuGet cache, source code, and intermediate
# build artefacts from stages 1 and 2 are never present here.
# ════════════════════════════════════════════════════════════════════════════
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime
# aspnet:8.0-bookworm-slim: Debian-based, glibc-compatible, ~220MB.
# Prefer over untagged :8.0 (larger) and :8.0-alpine (musl, compatibility risk).
# Pin to a specific tag — never use :latest in production Dockerfiles.

WORKDIR /app

# ── Security: non-root user ───────────────────────────────────────────────
# .NET 8 Microsoft base images include a built-in 'app' user (uid/gid 1000).
# Switch to it before copying application files — files land with app ownership.
USER app
# If your base image does not include a pre-built app user, create one:
# RUN addgroup --system --gid 1001 appgroup \
#  && adduser  --system --uid  1001 --ingroup appgroup --no-create-home appuser
# USER appuser

# Copy published output from the build stage only — no SDK, no source
COPY --from=build --chown=app:app /app/publish .

# ── Port configuration ────────────────────────────────────────────────────
# ASP.NET Core 8 defaults to port 8080 when running as non-root.
# Set ASPNETCORE_HTTP_PORTS explicitly — do not rely on implicit defaults.
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080

# ── Entry point ───────────────────────────────────────────────────────────
# Use exec form (JSON array) — not shell form ("dotnet MyApp.dll").
# Exec form: process receives OS signals (SIGTERM) directly — graceful shutdown.
# Shell form: signals go to the shell wrapper, not the .NET process — abrupt kill.
ENTRYPOINT ["dotnet", "MyApp.dll"]

Three decisions in this Dockerfile are worth making explicit for your team. First, the restore stage is separate from the build stage specifically for layer caching — dotnet restore is the slowest step in a .NET Docker build, and isolating it means CI rebuilds skip it entirely when only application source changes. Second, --chown=app:app on the COPY instruction sets file ownership at copy time, in a single layer, without a separate RUN chown instruction that would double the layer size. Third, exec-form ENTRYPOINT is not optional — shell-form entry points wrap the process in /bin/sh -c, which intercepts SIGTERM. Kubernetes sends SIGTERM before force-killing a pod. If your process never receives it, you get abrupt termination instead of graceful shutdown, and in-flight requests die mid-response.

The .dockerignore That Keeps Your Layers Clean

The .dockerignore file is processed before any Dockerfile instruction runs. Every file it excludes is never sent to the Docker daemon as part of the build context — which means it cannot appear in any layer, even accidentally. A missing or incomplete .dockerignore is the most common way secrets, credentials, and local configuration files end up baked into Docker images that get pushed to registries. It also determines whether your build context is 2MB or 400MB, which directly affects build startup time.

.dockerignore — Complete Configuration for ASP.NET Core Projects
# ── Build output — always exclude ────────────────────────────────────────
# Local bin/ and obj/ conflict with Docker's own build step.
# Including them causes layer cache misses and may introduce locally compiled
# binaries that differ from what dotnet publish produces inside the container.
**/bin/
**/obj/

# ── Version control ───────────────────────────────────────────────────────
# .git/ can be hundreds of megabytes of history.
# No Docker build step needs it. Never include it.
.git/
.gitignore
.gitattributes

# ── IDE and OS artefacts ──────────────────────────────────────────────────
.vs/
.vscode/
*.user
*.suo
.DS_Store
Thumbs.db

# ── Local secrets and environment configuration ───────────────────────────
# These must NEVER enter a Docker build context.
# Even if you don't explicitly COPY them, the daemon receives them.
# A misconfigured intermediate stage or a build cache leak can expose them.
.env
.env.*
**/appsettings.Development.json
**/appsettings.Local.json
**/secrets.json
**/*.pfx
**/*.p12
**/*.key

# ── Test projects ─────────────────────────────────────────────────────────
# Test output does not belong in a production runtime image.
# If your Dockerfile COPY step is broad (COPY . .), exclude test projects here.
**/tests/
**/test/
**/*.Tests/
**/*.IntegrationTests/

# ── Docker files themselves ───────────────────────────────────────────────
# Dockerfile and compose files don't need to be inside the image.
Dockerfile
Dockerfile.*
docker-compose*.yml
.dockerignore

# ── Documentation and CI configuration ───────────────────────────────────
README.md
docs/
.github/
.gitlab-ci.yml
azure-pipelines.yml

The secrets block deserves emphasis. appsettings.Development.json frequently contains local database connection strings, API keys for development services, and OAuth client secrets. It is committed to source control, present in every developer's working directory, and will silently enter your Docker build context if not excluded. The same applies to .env files used by Docker Compose for local development — they are in the project root, which is typically the Docker build context root. Add them to .dockerignore before your first docker build, not after discovering them in an image layer.

Base Image Choice & What It Does to Your Image Size

The choice of runtime base image is the single biggest lever on final image size after multi-stage builds. Microsoft publishes several ASP.NET Core runtime image variants for each .NET version. The differences are not cosmetic — they affect image size, glibc compatibility, available system tooling, and CVE exposure surface. The right choice depends on your application's native dependency requirements and your team's tolerance for musl libc compatibility testing.

BaseImageComparison — ASP.NET Core 8 Runtime Image Options
# ════════════════════════════════════════════════════════════════════════════
# ASP.NET CORE 8 RUNTIME BASE IMAGE OPTIONS
# ════════════════════════════════════════════════════════════════════════════
#
# ┌─────────────────────────────────────┬────────────┬───────────┬──────────┐
# │ Image Tag                           │ Base OS    │ Approx.   │ Use When │
# │                                     │            │ Size      │          │
# ├─────────────────────────────────────┼────────────┼───────────┼──────────┤
# │ aspnet:8.0                          │ Debian 12  │ ~220MB    │ Never in │
# │ (untagged / default)                │ (full)     │           │ prod     │
# ├─────────────────────────────────────┼────────────┼───────────┼──────────┤
# │ aspnet:8.0-bookworm-slim  ← DEFAULT │ Debian 12  │ ~220MB*   │ Default  │
# │                                     │ (slim)     │           │ prod     │
# ├─────────────────────────────────────┼────────────┼───────────┼──────────┤
# │ aspnet:8.0-alpine                   │ Alpine 3   │ ~115MB    │ When no  │
# │                                     │ (musl)     │           │ native   │
# │                                     │            │           │ deps,    │
# │                                     │            │           │ tested   │
# ├─────────────────────────────────────┼────────────┼───────────┼──────────┤
# │ aspnet:8.0-noble-chiseled           │ Ubuntu 24  │ ~115MB    │ Distro-  │
# │ (Chiseled Ubuntu)                   │ (chiseled) │           │ less,    │
# │                                     │            │           │ no shell │
# └─────────────────────────────────────┴────────────┴───────────┴──────────┘
#
# * bookworm-slim and the untagged default are currently equivalent in size.
#   The untagged default will track future Debian releases — pin bookworm-slim
#   for reproducible builds.
#
# ── Recommendation ────────────────────────────────────────────────────────
# bookworm-slim: safe default for all applications.
# alpine: only after explicit testing — musl vs glibc issues are silent at
#         build time and surface as runtime crashes or incorrect behaviour.
# chiseled: excellent attack surface reduction, no shell (no exec into it),
#           requires your app to have no shell-dependent startup scripts.

# ── Concrete size comparison in a multi-stage build ──────────────────────
# Single-stage (sdk:8.0):              ~750MB
# Multi-stage + aspnet:8.0 (untagged): ~220MB   (70% reduction)
# Multi-stage + bookworm-slim:         ~220MB   (70% reduction)
# Multi-stage + alpine:                ~115MB   (85% reduction)
# Multi-stage + chiseled:              ~115MB   (85% reduction)

# ── Pinning to a specific digest for reproducible builds ─────────────────
# Tags are mutable — :8.0-bookworm-slim can change on the next Microsoft patch.
# For fully reproducible production builds, pin to the image digest:
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim@sha256: AS runtime
# Retrieve current digest:
# docker inspect mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim \
#   --format='{{index .RepoDigests 0}}'
# Update the digest as part of your scheduled dependency update process.

Chiseled Ubuntu images deserve a note. They are a relatively recent addition to Microsoft's image catalogue and represent the most aggressive attack surface reduction available — no shell, no package manager, no utilities beyond what ASP.NET Core needs to run. An attacker who gains code execution in a chiseled container cannot open a shell, cannot install tools, and cannot enumerate the filesystem with standard Unix utilities. The trade-off is that you cannot docker exec into a running chiseled container for debugging — which is the correct production behaviour, not a limitation. Use distroless/chiseled in production, use a debug variant locally if you need interactive access.

Verifying Your Production Dockerfile Is Correct

Writing the Dockerfile is step one. Verifying that it produces what you intended — correct image size, non-root process, no leaked files, clean layers — is step two and is frequently skipped. These four verification commands should run in CI on every image build before the image is pushed to any registry.

verify-dockerfile.sh — Four Checks Every CI Pipeline Should Run
# ════════════════════════════════════════════════════════════════════════════
# BUILD THE IMAGE
# ════════════════════════════════════════════════════════════════════════════
docker build \
  --tag myapp:ci \
  --file Dockerfile \
  .

# Pass --no-cache in CI to guarantee a clean build independent of layer cache:
# docker build --no-cache --tag myapp:ci .


# ════════════════════════════════════════════════════════════════════════════
# CHECK 1: Image size
# Verify the final image is within your expected size budget.
# A multi-stage build against bookworm-slim should be well under 300MB
# for a typical ASP.NET Core API.
# ════════════════════════════════════════════════════════════════════════════
docker image inspect myapp:ci \
  --format='Image size: {{.Size}} bytes'

# Human-readable size via docker images:
docker images myapp:ci --format "Size: {{.Size}}"
# Expected: ~220-260MB for bookworm-slim, ~120-150MB for alpine


# ════════════════════════════════════════════════════════════════════════════
# CHECK 2: Running user — must NOT be root
# ════════════════════════════════════════════════════════════════════════════
RUNNING_USER=$(docker run --rm myapp:ci whoami)
echo "Running as: $RUNNING_USER"

if [ "$RUNNING_USER" = "root" ]; then
  echo "FAIL: Container is running as root. Add USER app to Dockerfile."
  exit 1
fi
echo "PASS: Non-root user confirmed ($RUNNING_USER)"


# ════════════════════════════════════════════════════════════════════════════
# CHECK 3: SDK not present in the runtime image
# If the SDK leaked into the final image, dotnet --list-sdks returns output.
# In a correctly built multi-stage image, only the runtime is present —
# dotnet --list-sdks returns nothing.
# ════════════════════════════════════════════════════════════════════════════
SDK_LIST=$(docker run --rm myapp:ci dotnet --list-sdks 2>/dev/null || true)

if [ -n "$SDK_LIST" ]; then
  echo "FAIL: .NET SDK is present in the runtime image: $SDK_LIST"
  echo "      Check your FROM statements — SDK stage may be the final stage."
  exit 1
fi
echo "PASS: No SDK present in runtime image"


# ════════════════════════════════════════════════════════════════════════════
# CHECK 4: Secrets files not present in the image
# Verify that appsettings.Development.json and .env are not in the image.
# These should be excluded by .dockerignore — this confirms they are.
# ════════════════════════════════════════════════════════════════════════════
DEV_SETTINGS=$(docker run --rm myapp:ci \
  sh -c "find /app -name 'appsettings.Development.json' 2>/dev/null" || true)

if [ -n "$DEV_SETTINGS" ]; then
  echo "FAIL: appsettings.Development.json found in image at: $DEV_SETTINGS"
  echo "      Add appsettings.Development.json to .dockerignore"
  exit 1
fi
echo "PASS: No development secrets files found in image"

echo ""
echo "All checks passed. Image myapp:ci is production-safe."

Check 2 — the running user check — is the most commonly skipped verification and the one most likely to catch a Dockerfile that was modified after the initial review. A well-intentioned change to the build stage order, a base image upgrade, or a copy-paste from an internal wiki can silently remove the USER app directive. Automating this check in CI means the regression is caught at build time, not when a security scan flags the deployed image in production. All four checks run in seconds on any CI platform and require no additional tooling beyond the Docker CLI.

What Developers Want to Know

Why does a multi-stage Dockerfile produce a smaller image than a single-stage build?

A single-stage build copies the full .NET SDK into the image — compilers, MSBuild, NuGet cache, intermediate build artefacts — all needed to build but useless at runtime. The SDK image for .NET 8 is roughly 750MB. A multi-stage build uses the SDK only in the build stage, then copies only the published output into a separate runtime base image. The ASP.NET Core runtime image (aspnet:8.0-bookworm-slim) is around 220MB. Everything the SDK brought in never enters the final image — smaller image, faster pull, smaller attack surface, no compiler toolchain in production.

Which base image should I use for production — alpine, slim, or the default?

For most ASP.NET Core applications, aspnet:8.0-bookworm-slim is the safest production default. It is Debian-based, fully glibc-compatible, and significantly cleaner than the untagged default. Alpine (aspnet:8.0-alpine) is the smallest option — under 120MB — but uses musl libc. Some .NET workloads and third-party libraries that link against glibc behave incorrectly on musl. If your application has no native dependencies and you have tested it on Alpine, Alpine is a reasonable choice. If you are unsure, use bookworm-slim — it eliminates the musl compatibility class of problems while still giving you a production-appropriate image size.

Why should I run an ASP.NET Core container as a non-root user?

Running as root inside a container means that if an attacker exploits your application, they gain root-level access inside the container — which, depending on runtime configuration, can translate to elevated host access. Running as a non-root user limits the blast radius to the permissions of the app user only. It also satisfies common compliance requirements (PCI-DSS, SOC 2) that mandate least-privilege process execution. The .NET 8 Microsoft base images ship with a built-in app user (uid 1000) — switching to it is a single USER app directive in your Dockerfile.

What should I put in my .dockerignore for an ASP.NET Core project?

At minimum: **/bin/, **/obj/, .git/, .vs/, *.user, and any .env files or local secrets. The bin/ and obj/ directories conflict with Docker's own build step and cause layer cache misses. The .git/ directory adds hundreds of megabytes to the build context. Development appsettings files and .env files must never enter a Docker build context — they can leak into image layers even if you never explicitly COPY them, because the full context is sent to the daemon before any instruction runs.

Should I use dotnet publish --self-contained in my Dockerfile?

Not by default. Self-contained publish bundles the .NET runtime into your application output, which increases published size significantly and removes the benefit of a slim runtime base image — you are shipping the runtime twice. Framework-dependent publish (the default) produces a smaller output and relies on the runtime already present in your aspnet base image. Self-contained is appropriate when deploying to an environment with no .NET runtime available, or when using NativeAOT for startup performance. For standard Docker deployments against Microsoft's official ASP.NET Core base images, framework-dependent publish is the correct choice.

Back to Articles