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.
# ── 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.
# ════════════════════════════════════════════════════════════════════════════
# 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.
# ── 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.
# ════════════════════════════════════════════════════════════════════════════
# 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.
# ════════════════════════════════════════════════════════════════════════════
# 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.