All articles

Modern .NET Backend Roadmap — Part 6: Docker

Package the API as a container: multi-stage Dockerfile, layer-cache-friendly restore, non-root user, and the two real build failures you will hit — verified with a running container.

0 · log in to like, save & follow Share on LinkedIn Share on X

"Works on my machine" stops being a joke the day you ship a container: the image is the machine. This part packages the roadmap API into a Docker image with a multi-stage build, runs it as a non-root user, and walks through the two real failures we hit while building it — both of which you will hit too, so let's meet them here instead of in production.

Modern .NET Backend Roadmap — Part 6: Docker

The multi-stage Dockerfile

# Build stage: SDK image compiles and publishes the app.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# Copy project files first so dependency restore is cached between builds.
COPY ModernDotNetBackend.sln .
COPY src/Roadmap.Domain/Roadmap.Domain.csproj src/Roadmap.Domain/
COPY src/Roadmap.Application/Roadmap.Application.csproj src/Roadmap.Application/
COPY src/Roadmap.Infrastructure/Roadmap.Infrastructure.csproj src/Roadmap.Infrastructure/
COPY src/Roadmap.Api/Roadmap.Api.csproj src/Roadmap.Api/
RUN dotnet restore src/Roadmap.Api/Roadmap.Api.csproj

COPY src/ src/
RUN dotnet publish src/Roadmap.Api/Roadmap.Api.csproj -c Release -o /app/publish --no-restore

# Runtime stage: small ASP.NET image, non-root user.
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080

RUN mkdir /data && chown $APP_UID /data
ENV ConnectionStrings__Default="Data Source=/data/roadmap.db"
USER $APP_UID
ENTRYPOINT ["dotnet", "Roadmap.Api.dll"]

Why each choice:

  • Two stages. The SDK image is ~800 MB of compilers you must not ship. The runtime image carries only ASP.NET Core and your published output.
  • csproj-first COPY. Docker caches layers by content. Copying only project files before dotnet restore means source edits reuse the (slow) restore layer; only dependency changes re-run it. This is the difference between 8-second and 3-minute rebuilds.
  • USER $APP_UID. Modern .NET images define a non-root user; a container escape from root is a much worse day than one from uid 1654.

Failure #1: the obj/ directory time bomb

Our first build died at dotnet publish with:

error NETSDK1064: Package Microsoft.AspNetCore.OpenApi, version 10.0.1 was not found.

Restore had succeeded — inside the container. But COPY src/ src/ then copied the host's obj/ directories over it, and those contained macOS restore state pointing at NuGet paths that don't exist in the image. The fix is a .dockerignore, which you should write before your first build, not after your first failure:

**/bin/
**/obj/
**/roadmap.db
.git/

Bonus: it also keeps your build context small, which speeds every build.

Failure #2: non-root meets SQLite

Second run: the container started, then crashed on first query:

Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 14: 'unable to open database file'.

WORKDIR /app is owned by root; USER $APP_UID is not root; SQLite needs to create a file. Hence the two lines in the runtime stage: create /data, chown it to the app user, and point the connection string there via environment variable. The same shape applies to any file the app writes — uploads, data protection keys, logs. In real deployments you mount a volume at /data; for client-server databases the problem disappears because the app only opens sockets.

Build, run, verify

docker build -t roadmap-api .
docker run -d --rm -p 8085:8080 --name roadmap-demo roadmap-api
curl http://localhost:8085/products

Verified output from the container:

[{"id":1,"name":"Mechanical keyboard","price":89.0,"stock":14},
 {"id":2,"name":"4K monitor","price":329.99,"stock":2}, ...]

The exact bytes that ran on the laptop will run on the server — that's the contract Docker actually delivers.

Configuration crosses the boundary as environment

Notice the pattern already at work: ConnectionStrings__Default (double underscore = : in .NET config) overrides appsettings.json from the environment. Everything environment-specific — connection strings, the JWT key from part 4, feature flags — enters this way. The image stays identical from dev to prod; only the environment changes. This is twelve-factor config, and .NET's configuration system supports it natively.

geeksarray.com itself runs exactly this shape: one Compose file with the app container (built from a Dockerfile like this one), PostgreSQL, and a Caddy reverse proxy for TLS — secrets in a server-side .env, never in the image.

Compose: the API with a real database

The single-container demo uses SQLite; the moment you add PostgreSQL, docker compose is the local orchestrator. This is the same shape geeksarray.com deploys with:

services:
  api:
    build: .
    ports: ["8080:8080"]
    environment:
      ConnectionStrings__Default: "Host=db;Database=roadmap;Username=app;Password=${DB_PASSWORD}"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: roadmap
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes: ["pgdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d roadmap"]
      interval: 5s
      retries: 10

volumes:
  pgdata:

The details that bite:

  • Host=db — service names are DNS names on the Compose network; localhost inside the api container is the api container.
  • condition: service_healthy waits for Postgres to accept connections, not merely start. Without the healthcheck, your migrations race the database and lose intermittently — we hit exactly this during the geeksarray launch.
  • The named volume is where the data lives; docker compose down -v deletes it. Learn the difference before it teaches you.
  • ${DB_PASSWORD} resolves from a .env file next to the compose file — which never enters git or the image.

Debugging containers when things go wrong

The four commands that answer 90% of "it works locally but not in the container":

docker logs roadmap-demo --tail 50      # what did the app actually say?
docker exec -it roadmap-demo sh         # look around inside (aspnet images have sh)
docker inspect roadmap-demo             # env vars, mounts, ports as the container sees them
docker stats                            # is it starving for memory/CPU?

Habits that shorten the loop: check docker logs before theorizing (our SQLite failure printed its exact cause); use docker exec ... env to confirm the configuration the process actually received, not the one you intended; and remember EXPOSE documents a port but publishes nothing — only -p host:container does.

Image hygiene quick hits

  • Pin base image tags (sdk:10.0, not latest).
  • One process per container; the database is a separate container/service, not a sidecar process.
  • Add a HEALTHCHECK (or orchestrator probe) hitting a cheap endpoint.
  • Scan images (docker scout, Trivy) in CI — which is convenient, because CI is exactly where we're headed.

Shrinking the image: what actually moves the needle

The multi-stage build already cut ~800 MB of SDK; the remaining levers, in descending payoff:

  • aspnet:10.0-alpine — the musl-based runtime image drops another ~100 MB versus Debian. The trade: occasional native-dependency friction (globalization needs icu-libs, or set InvariantGlobalization=true for APIs that don't localize).
  • Trimmed, ahead-of-time published binariesdotnet publish -c Release /p:PublishTrimmed=true (and for minimal APIs, PublishAot=true) can produce a self-contained app small enough for the runtime-deps base image. Trimming requires your code and libraries to be trim-safe; EF Core's runtime model building historically isn't, which is why this series doesn't default to it.
  • One RUN, fewer layers — consolidating apt-get/cleanup chains matters for images that install OS packages; our runtime stage installs nothing, so there's little to win.
  • docker history roadmap-api — before optimizing, look. The layer sizes tell you exactly where the megabytes live, and the answer is usually "the publish output and the base image," not the thing you were about to golf.

Size matters less for its own sake than for what it proxies: pull time on cold nodes (scaling speed), attack surface (fewer packages, fewer CVEs in the scanner report), and registry cost. An 110 MB Alpine image with a clean Trivy scan is a fine resting place; chasing the last 30 MB into AOT territory is only worth it when cold-start latency is a product requirement.

Next

We can build an image by hand; part 7 gives it a home in the cloud — Azure App Service and Container Apps, pushing to a registry, and wiring secrets — and part 8 makes both the build and the deploy happen automatically on every push.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.