Everything so far ran other people's images. This part builds your own: the clinic API as a container image, first with a two-stage Dockerfile and then with nothing but dotnet publish, which the SDK can do without a Dockerfile at all. On the way it measures what the build cache saves, breaks the build by leaving .dockerignore out, shows why the final image does not contain the SDK, picks the wrong base image on purpose, and asks for HTTPS inside a container that has no certificate. Every output is quoted from the run.

Steps in this part
  1. Download the Dockerfile, .dockerignore, ClinicApi.csproj and Program.cs into a folder, with the db folder from Part 5
  2. docker build --no-cache -t clinicapi ., then the same without the flag, then after a one-line change
  3. Rename .dockerignore away, build again, read the failure; put it back
  4. docker images and docker history clinicapi: find the SDK (you will not)
  5. Run it with -p 15870:8080, curl /, then /health and read the log
  6. dotnet publish --os linux --arch x64 -c Release /t:PublishContainer with no Dockerfile and compare the two images
  7. Change the base image to runtime:10.0, build, run, and read the exit

Two stages, three builds

The Dockerfile has a build stage on sdk:10.0 that copies the project file, restores, copies the rest and publishes, and a final stage on aspnet:10.0 that copies only the publish output. The order of the two COPY lines is the whole trick, and three builds show why:

BuildTimeWhat ran
First build (--no-cache)10.54 srestore 3.9 s, publish 3.7 s, export 0.8 s
Same command, nothing changed0.93 severy step CACHED
After a comment added to Program.cs7.04 srestore CACHED, COPY . . and publish again

The first row needed --no-cache only because this PC had already built the same files that morning; the cache is machine-wide and keyed on the instructions and the file contents, not on the folder or the tag. The third row is the one that matters every day: because the project file was copied and restored before the source, a source change leaves the restore layer cached and skips the package download. Put COPY . . first and every edit restores again.

Why .dockerignore is about correctness, not size

With .dockerignore renamed away, the build sent 1.97 MB of context instead of a few kilobytes of source, and it did not merely get slower. It failed:

error MSB4018: NuGet.Packaging.Core.PackagingException: Unable to find fallback package folder 'C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages'.

The host's obj folder, copied by COPY . ., overwrote the restore the container had just done, and its project.assets.json named Windows package folders that do not exist in Linux. The four lines in the reader file, bin/, obj/, .vs/ and *.user, are the difference between a working build and that message. With the file back, the context was bytes and every step was cached again.

What is in the image, and what is not

dw-p7-api:latest   ...        342MB         96.5MB

342 MB on disk, 96.5 MB compressed; the aspnet:10.0 base alone is 340 MB and 95.9 MB, so the app added about two megabytes. The sdk:10.0 image is 1.25 GB. docker history shows the app's own layer, COPY /app . # buildkit, at 1.63 MB on top of the aspnet layers, and no layer from the build stage, because the final image is only its last stage and the only thing taken from the first is what COPY --from=build /app . names. Asked directly, dotnet --list-sdks inside the image printed nothing, and --list-runtimes listed exactly two frameworks, ASP.NET Core and .NET 10.0.13. The documentation's one sentence: multi-stage builds "optimize the final size of the image by layering the build and leaving only required artifacts."

Running it, and the one thing the image must not contain

docker run -d -p 15870:8080 and a curl of the root:

{"app":"ClinicApi","machine":"ff61b64a81ef"}

The container's hostname is its id, the log says Now listening on: http://[::]:8080 and Hosting environment: Production, and /health returned a 500. The log explains:

Npgsql.NpgsqlException (0x80004005): Failed to connect to 127.0.0.1:15850

No connection string was passed, so the app fell back to its localhost default, and inside a container localhost is the container (Part 4). The image must not carry a connection string, and the run must: with -e "ConnectionStrings__Clinic=Host=host.docker.internal;Port=15871;..." pointing at a PostgreSQL container published on 15871, /health said reachable and /patients returned the three patients. The double underscore is how an environment variable spells the colon in ConnectionStrings:Clinic.

The same image without a Dockerfile

The SDK has had its own container publisher since .NET 7, and the documented form needs no file:

dotnet publish --os linux --arch x64 -c Release /t:PublishContainer -p ContainerRepository=dw-p7-api-sdk-linux -p ContainerImageTag=latest
Building image 'dw-p7-api-sdk' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0'.
Pushed image 'dw-p7-api-sdk:latest' to local registry via 'docker'.

Nothing had to be configured: the documentation says that for an ASP.NET Core project "the mcr.microsoft.com/dotnet/aspnet image is used as the base image", with the tag taken from the target framework, and the port read from the base image's ASPNETCORE_HTTP_PORTS. The result was 342 MB and 96.5 MB, byte for byte the sizes of the Dockerfile image, and answered on its port the same way. Two differences showed up in docker image inspect: the SDK image runs as the non-root user 1654, named app, while the Dockerfile image runs as root because the Dockerfile has no USER line; and the SDK image sets no ASPNETCORE_URLS, so its log has no "Overriding HTTP_PORTS" warning. The first publish took 31 seconds and the second 9, which is the base image being fetched once. Without --os linux --arch x64 the publish was portable and the image contained a Windows ClinicApi.exe next to the dll; it still ran, because the entrypoint is dotnet /app/ClinicApi.dll, but it is not what you want to ship.

The wrong base image

The final stage changed to mcr.microsoft.com/dotnet/runtime:10.0 built without complaint, 40 MB smaller, and the container exited with code 150:

You must install or update .NET to run this application.

App: /app/ClinicApi.dll
Architecture: x64
Framework: 'Microsoft.AspNetCore.App', version '10.0.0' (x64)
.NET location: /usr/share/dotnet/

No frameworks were found.

Same first line as the version-mismatch post, different cause: the version is right and the framework is missing, because the runtime image has only Microsoft.NETCore.App. Web apps go on aspnet; the documentation's console-app tutorial notes that its choice of the ASP.NET image is deliberate even where runtime would do.

HTTPS inside a container

With ASPNETCORE_URLS=https://+:8443, the container exited at once:

System.InvalidOperationException: Unable to configure HTTPS endpoint. No server certificate was specified, and the default developer certificate could not be found or is out of date.

Your development certificate lives in your Windows profile, not in the image. The Fixes post shows the dev-certs fix inside a container; for anything beyond a demo, run HTTP inside and terminate TLS at a reverse proxy, or mount a real certificate and name it in Kestrel's configuration.

Which one to use: the SDK publisher for a plain .NET app, because it needs no file, picks the right base and runs as a non-root user; a Dockerfile when the image needs anything the project does not describe, such as native packages, a second tool, or a USER of your own.

Frequently asked

How do I build a Docker image for an ASP.NET Core app?
Either a two-stage Dockerfile, sdk for the build stage and aspnet for the final one, copying the project file and restoring before copying the source so that edits do not repeat the restore, with a .dockerignore for bin and obj; or no Dockerfile at all: dotnet publish --os linux --arch x64 -c Release /t:PublishContainer, which picks the aspnet base image and the port itself and runs as a non-root user.
Why does my Docker build fail with Unable to find fallback package folder?
The build context included the host's obj folder, and its project.assets.json points at Windows package folders that do not exist in the Linux build container, so the publish step cannot resolve packages. Add a .dockerignore with bin/ and obj/ and build again.
Why does the container say You must install or update .NET when the version is right?
The base image has the .NET runtime but not the ASP.NET Core framework. A web app built on mcr.microsoft.com/dotnet/runtime prints No frameworks were found for Microsoft.AspNetCore.App and exits with code 150. Use mcr.microsoft.com/dotnet/aspnet as the final stage.

Next: Part 8, the daily loop and the clean-up: what docker system df counts, what prune would remove, restart policies, healthchecks and a check script that builds, starts and tests before every push.