Cloud Modernization Patterns Module 2 · Where Should It Run?

Containerizing an Existing App

Last reviewed · content updated

Intermediate

What you'll learn

~20 min
  • Explain why .NET Framework 4.8 blocks Linux containers -- and what the port to .NET 10 unlocks
  • Critique a naive generated Dockerfile and rebuild it with multi-stage builds, correct layer order, non-root user, and a healthcheck
  • Spot the classic compose-file gotchas before they cost you an afternoon

The gate before the gate

AssetTrack’s API is headed for Azure Container Apps (rung 3 on the ladder). But there is a gate: .NET Framework 4.8 runs only on Windows. Windows containers exist, but they are large, slower to schedule, and poorly supported across managed container platforms. The realistic path — and the one Meridian’s roadmap already scheduled as Phase 3 — is the port to modern .NET, which runs natively on Linux.

This is worth internalizing beyond .NET: containerization is rarely the first move on a legacy app. The runtime modernization comes first, and the container is its payoff.

The port itself is a lesson-sized topic in AI-assisted migration: characterization tests around the batch outputs first (Lesson 1.2’s risk register demanded this), then service-by-service porting with your AI CLI doing the mechanical API translation and you reviewing the diffs. Assume that work is done; today is about packaging its output.

Prompt first — then trust nothing

Write a production Dockerfile for a .NET 10 web API. Requirements:
multi-stage build, layer caching that survives code changes, non-root
runtime user, and a container-level healthcheck. Then explain each choice
in one line.

Asking for the explanations is not decoration — it is how you catch the places where the AI pattern-matched instead of engineered. Here is a naive first cut you will often get from a less careful prompt, worth dissecting because every flaw in it appears constantly in real repos:

Dockerfile.naive — three problems
FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o /out
EXPOSE 8080
ENTRYPOINT ["dotnet", "/out/AssetTrack.Api.dll"]

Problem 1 — single stage. The shipped image contains the entire SDK: compilers, build tools, hundreds of megabytes of attack surface that will never run in production.

Problem 2 — COPY . . before the restore. Docker caches layers in order. Copying all source first means any code change invalidates the dependency-restore layer, so every CI build re-downloads every package. Copy the project files, restore, then copy source.

Problem 3 — runs as root. Default user in most base images is root. A container escape from a root process is a much worse day than one from an unprivileged user.

The production version

Dockerfile — multi-stage, cached, non-root
# --- build stage: SDK lives here and only here ---
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# dependency layer: invalidated only when the project file changes
COPY AssetTrack.Api.csproj .
RUN dotnet restore
# source layer: invalidated on code changes, restore cache survives
COPY . .
RUN dotnet publish -c Release -o /out --no-restore
# --- runtime stage: only the ASP.NET runtime and the app ---
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /out .
# slim base images ship no probe tooling -- install what the healthcheck needs
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# .NET base images ship a built-in non-root user
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \
CMD curl -fsS http://localhost:8080/healthz || exit 1
ENTRYPOINT ["dotnet", "AssetTrack.Api.dll"]

Four choices, four one-line justifications:

  • Multi-stage: the runtime image ships no SDK — smaller, faster to pull, less to exploit
  • csproj-then-restore-then-source: code edits no longer bust the package cache; CI builds drop from minutes to seconds
  • Non-root user: blast-radius reduction that costs one line — the official .NET images ship an unprivileged app user precisely so you will use it
  • HEALTHCHECK against a real endpoint: “the process is running” and “the app is serving” are different facts. Two honesty notes: the slim base image ships neither wget nor curl, so the check’s tool must be installed or the check silently cannot run; and Kubernetes and Container Apps ignore Docker HEALTHCHECK entirely — they configure probes at the platform layer (Lessons 2.3 and 2.4 do exactly that), so this line earns its keep in docker and compose runs. The /healthz endpoint itself, though, is the asset every layer reuses — remember it in Lesson 2.5, where probes that test the wrong thing become a finding.

Compose for local dev — two classic gotchas

For local development, a compose file wires the API to a local database. Generated and hand-me-down compose files repeatedly hide the same two traps:

compose.yaml — spot the trap
services:
api:
image: registry.example.com/assettrack-api:latest # <-- trap 1
ports: ["8080:8080"]
depends_on: ["db"] # <-- trap 2 (weaker than it looks)
db:
# same engine as prod (Lesson 1.2's risk register: Oracle behavior may
# differ under the new data layer) -- Database Free, not 19c: a known,
# flagged delta beats silently developing against a different engine
image: gvenzl/oracle-free:23-slim
environment:
ORACLE_PASSWORD: localdev

Trap 1: image: instead of build:. This service pulls the published image from the registry — your local code changes never reach the container, and you can lose an afternoon debugging code that is not running. For dev, you want build: . (optionally keeping image: as the tag name for the built result).

Trap 2: bare depends_on only orders startup, it does not wait for readiness. The API starts the moment the database container exists, not when the database is accepting connections. Use the healthcheck-aware form: depends_on: { db: { condition: service_healthy } } with a healthcheck on the db service — or make the app retry its startup connection, which production will require of it anyway.

💡Have the AI review its own artifact

Generation and review are different modes. After any Dockerfile or compose file is generated, run: “Adversarially review this file for build-cache waste, security posture, and local-dev traps. Assume nothing works until proven.” The Dockerfile above survives that review; the naive one does not — and watching the AI find its own earlier shortcuts is the fastest way to learn the checklist yourself.

KNOWLEDGE CHECK

Your CI builds re-download every NuGet package on every commit, taking 4 minutes. The Dockerfile starts with 'COPY . .' followed by 'RUN dotnet restore'. What is the fix?

Key takeaway

Runtime modernization unlocks containerization, not the other way around. Then four habits make the container production-grade: multi-stage builds, dependency-before-source layer order, a non-root user, and a healthcheck that tests the app rather than the process. Next lesson: running this container with zero cluster operations.

Search lessons