Multi-stage Docker images for .NET + Angular
node_modules that often weighs several hundred megabytes. None of these tools are needed at runtime. Embedding them in the image you deploy means shipping an entire compilation toolchain to launch a binary that no longer needs it.
The principle: compile then discard
Dockerfile declares several FROM . Each FROM opens an isolated stage, with its own base image and its own filesystem. Only the last stage becomes the shipped image; all the preceding ones serve solely to produce artifacts.
COPY --from . The SDK, the sources, the restore caches stay behind and never reach the final image. The build stage does the heavy lifting; the final stage receives only the result.
The .NET API Dockerfile
.csproj files and restore before copying the code. The restore is then only replayed when a dependency changes. A change in a C# file only invalidates the publish , which starts from an already-warm NuGet cache. On a real codebase, that makes the difference between a build that takes a few seconds and a full restore on every commit.
--no-restore flag on dotnet publish prevents it from triggering another restore: the previous layer already handled it. Watch out for the multi-project solution trap: if Api references Domain , both .csproj files need to be copied to their location before the restore , otherwise the restore fails because it can't find the reference graph.
app user, UID 1654). On a standard aspnet image, the process runs as root unless stated otherwise; you then switch explicitly with USER $APP_UID , the variable these images already define.
The Angular front end Dockerfile
npm ci is not npm install . It requires a present and consistent package-lock.json , removes the existing node_modules , and installs exactly the locked versions. It's reproducible and faster, which is precisely what you want in an image. A component change invalidates neither the lockfile nor the npm ci layer: the install stays cached, only the build is replayed.
ng build drops the bundle into dist/<app>/browser . The final stage copies only that folder into the nginx root.
nginx image launches its master process as root. The nginx-unprivileged variant runs entirely under a non-root account and listens on port 8080, which avoids opening a privileged port inside the container. The nginx.conf needs a single application-side directive: try_files $uri $uri/ /index.html , so that Angular's routing takes over instead of returning a 404 on a deep URL that gets reloaded.
The .dockerignore , without which the cache lies
.dockerignore , without which the cache lies Dockerfile , Docker sends the build context to the daemon. Without a filter, this context includes your bin/ , your obj/ , your local node_modules , the .git folder. Hundreds of megabytes transferred for nothing, and worse: a COPY . . copies these build artifacts from the host into the stage, where they can contradict what the container just restored.
.gitignore . By excluding output directories and local artifacts, you guarantee that the build starts from the sources alone and that the cache reflects reality. An obj/ dragged over from the dev machine is a classic cause of builds that "work on my machine" and break in CI.
Orchestrating both locally
docker-compose.yml wires the API and the front end on the same network and builds them with a single docker compose up .
--target , to stop the build at a specific stage (for example a test stage run in CI without producing the runtime image), and BuildKit cache mounts ( RUN --mount=type=cache ) that persist NuGet and npm caches between two builds.
A production image should only contain what runs. Multi-stage makes this discipline free: the SDK and Node stay in the build stages, never in what you deploy.