Containerizing an Existing App
Last reviewed · content updated
IntermediateWhat 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-rootruntime user, and a container-level healthcheck. Then explain each choicein 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:
FROM mcr.microsoft.com/dotnet/sdk:10.0WORKDIR /appCOPY . .RUN dotnet publish -c Release -o /outEXPOSE 8080ENTRYPOINT ["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
# --- build stage: SDK lives here and only here ---FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildWORKDIR /src# dependency layer: invalidated only when the project file changesCOPY AssetTrack.Api.csproj .RUN dotnet restore# source layer: invalidated on code changes, restore cache survivesCOPY . .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 runtimeWORKDIR /appCOPY --from=build /out .# slim base images ship no probe tooling -- install what the healthcheck needsRUN 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 userUSER appEXPOSE 8080HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \ CMD curl -fsS http://localhost:8080/healthz || exit 1ENTRYPOINT ["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
appuser 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
/healthzendpoint 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:
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: localdevTrap 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.
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.
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.