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.
- Download the Dockerfile, .dockerignore, ClinicApi.csproj and Program.cs into a folder, with the
dbfolder from Part 5 docker build --no-cache -t clinicapi ., then the same without the flag, then after a one-line change- Rename
.dockerignoreaway, build again, read the failure; put it back docker imagesanddocker history clinicapi: find the SDK (you will not)- Run it with
-p 15870:8080, curl/, then/healthand read the log dotnet publish --os linux --arch x64 -c Release /t:PublishContainerwith no Dockerfile and compare the two images- 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:
| Build | Time | What ran |
|---|---|---|
First build (--no-cache) | 10.54 s | restore 3.9 s, publish 3.7 s, export 0.8 s |
| Same command, nothing changed | 0.93 s | every step CACHED |
| After a comment added to Program.cs | 7.04 s | restore 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.