Docker Containerisation — Dockerfile and Multi-Stage Builds

Docker containerisation packages your ASP.NET Core application, its dependencies, and its runtime environment into a portable, reproducible image. The same image runs identically on a developer’s MacBook, a CI/CD runner, and a Kubernetes cluster. A multi-stage Dockerfile is the production-quality pattern: the SDK-based build stage compiles the application, and the smaller aspnet runtime stage packages only what is needed to run it — reducing the final image size by 70–80%.

Production Multi-Stage Dockerfile

// ── Dockerfile — multi-stage build for ASP.NET Core Web API ──────────────

# ── Stage 1: Restore — cache the NuGet restore layer ─────────────────────
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
WORKDIR /src

# Copy project files FIRST — restore layer is cached until .csproj changes
COPY ["src/BlogApp.Api/BlogApp.Api.csproj",             "src/BlogApp.Api/"]
COPY ["src/BlogApp.Application/BlogApp.Application.csproj", "src/BlogApp.Application/"]
COPY ["src/BlogApp.Infrastructure/BlogApp.Infrastructure.csproj", "src/BlogApp.Infrastructure/"]
COPY ["src/BlogApp.Domain/BlogApp.Domain.csproj",       "src/BlogApp.Domain/"]
RUN dotnet restore "src/BlogApp.Api/BlogApp.Api.csproj"

# ── Stage 2: Build ────────────────────────────────────────────────────────
FROM restore AS build
COPY . .
WORKDIR "/src/src/BlogApp.Api"
RUN dotnet build "BlogApp.Api.csproj" -c Release --no-restore -o /app/build

# ── Stage 3: Publish ──────────────────────────────────────────────────────
FROM build AS publish
RUN dotnet publish "BlogApp.Api.csproj" -c Release --no-build -o /app/publish \
    /p:PublishReadyToRun=true

# ── Stage 4: Final runtime image (small — aspnet only, no SDK) ──────────
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app

# Security: run as non-root user
RUN adduser --disabled-password --gecos "" appuser
USER appuser

# Copy published output from publish stage
COPY --from=publish --chown=appuser:appuser /app/publish .

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080
ENTRYPOINT ["dotnet", "BlogApp.Api.dll"]
Note: The key layer caching optimisation in the multi-stage Dockerfile is copying .csproj files before source code and running dotnet restore as a separate step. Docker caches each layer — if the .csproj files have not changed, the restore layer is reused from cache and dotnet restore does not run again. Since most code changes are to .cs files, the restore layer is cached on almost every build, saving 30–120 seconds of NuGet download time per build. This pattern is essential for fast CI builds.
Tip: Run the container as a non-root user (USER appuser in the Dockerfile). Container breakout vulnerabilities are mitigated when the container process does not have root privileges. The official aspnet base images in .NET 8 include a built-in app user — use USER app without needing to create one. Also, in .NET 8+, the default port changed from 80 to 8080 when running as non-root — set ENV ASPNETCORE_HTTP_PORTS=8080 to match.
Warning: Always create a .dockerignore file to prevent unnecessary files from being copied into the Docker build context. Without it, docker build sends your entire working directory (including node_modules, bin, obj, .git) to the Docker daemon, dramatically slowing builds and potentially including sensitive files in the image. Include at minimum: **/bin, **/obj, **/.vs, **/node_modules, .git, *.md.

.dockerignore

// ── .dockerignore ─────────────────────────────────────────────────────────
# Build outputs
**/bin/
**/obj/

# IDE and editor files
**/.vs/
**/.idea/
**/*.user

# Git
.git/
.gitignore

# Documentation and markdown
*.md
LICENSE

# Node (Angular frontend)
**/node_modules/

# Docker
Dockerfile
.dockerignore
docker-compose*.yml

# User secrets (never in image!)
**/secrets.json
**/appsettings.Development.json

Build and Run Commands

// ── Build the image ────────────────────────────────────────────────────────
docker build -t blogapp-api:latest .
docker build -t blogapp-api:1.2.3 .                    // versioned tag
docker build --no-cache -t blogapp-api:latest .         // ignore cache

// ── Run locally ───────────────────────────────────────────────────────────
docker run -d \
    --name blogapp-api \
    -p 8080:8080 \
    -e ConnectionStrings__Default="Server=host.docker.internal;..." \
    -e JwtSettings__SecretKey="dev-secret-key" \
    blogapp-api:latest

// ── Push to registry ──────────────────────────────────────────────────────
docker tag blogapp-api:latest myregistry.azurecr.io/blogapp-api:latest
docker push myregistry.azurecr.io/blogapp-api:latest

Common Mistakes

Mistake 1 — Not using multi-stage builds (large final image with SDK included)

❌ Wrong — single-stage build using SDK image is ~750MB; final image should use aspnet (~220MB).

✅ Correct — always separate build stage (SDK) from final stage (aspnet runtime).

Mistake 2 — Copying source before .csproj files (breaks layer caching)

❌ Wrong — every code change invalidates the restore layer, re-downloading all NuGet packages on every build.

✅ Correct — copy .csproj files, run restore, then copy source code — restore layer is cached.

🧠 Test Yourself

A multi-stage Dockerfile copies .csproj files and runs dotnet restore before copying source code. A developer changes a .cs file and rebuilds. What happens to the restore layer?