S

super-dev — full-stack

@super-dev.app · Full-stack · ★ open to work
12.8k subscribers·47 videos·Active since 2017

Full-stack .NET / Angular dev with a DevOps streak on Azure Cloud. Also fluent in Flutter + Firebase. I build products, ship them, and keep them healthy.

Back to articles
DEVOPS
DEVOPS

Multi-stage Docker images for .NET + Angular

Article 5 of 6 — Azure DevOps from scratch
Prerender, Container Apps, secrets, CI/CD, Docker, observability: ship to Azure without a server or the pain.

Shipping the .NET SDK and node_modules inside the image you deploy to production means shipping 800 MB of tooling that will never run at execution time. Multi-stage builds separate what compiles from what runs: you end up with a tiny final image containing only what the runtime strictly needs.

The principle: compile, then throw away

A multi-stage Dockerfile declares several FROM lines. Each FROM opens an isolated stage; only the last stage becomes the shipped image. You selectively copy the build artefacts from a build stage into a runtime stage, and everything else — SDK, sources, caches — is discarded.

Bash
1# Stage 1: build the .NET API
2FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
3WORKDIR /src
4COPY *.csproj ./
5RUN dotnet restore
6COPY . ./
7RUN dotnet publish -c Release -o /app/publish
8
9# Stage 2: runtime only (no SDK)
10FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final
11WORKDIR /app
12COPY --from=build /app/publish ./
13ENV ASPNETCORE_URLS=http://+:8080
14EXPOSE 8080
15ENTRYPOINT ["dotnet", "Api.dll"]

Layer caching: order to avoid rebuilding everything

Docker caches each instruction and invalidates it the moment an upstream layer changes. Hence the golden rule: copy the dependency files before the source code . By running COPY *.csproj then dotnet restore before copying the rest, the restore only re-runs when the .csproj changes — not on every C# edit. The same logic applies on the Angular side, with package.json and npm ci before the source COPY : a code change never re-invalidates the dependency install, which cuts build times tenfold.

A tiny final image

The choice of runtime base image makes all the difference. Microsoft's chiseled images ( aspnet:9.0-noble-chiseled ) strip out the shell, package manager and superfluous binaries: smaller attack surface, images often under 110 MB, and execution as a non-root user by default. To serve the Angular front end, nginx alpine plays the role of the final stage.

Bash
1# Build Angular, then serve it with nginx
2FROM node:22-alpine AS web
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci
6COPY . ./
7RUN npm run build
8
9FROM nginx:1.27-alpine AS final
10COPY --from=web /app/dist/browser /usr/share/nginx/html
11COPY nginx.conf /etc/nginx/conf.d/default.conf

The Angular build produces only static files: no Node runtime is needed in production. You copy the dist/browser folder into the nginx root and add a try_files $uri /index.html to the config for the SPA fallback .

Orchestrate locally with Compose

To run the API and front end together during development, a docker-compose.yml wires up both services and their network:

YAML
1services:
2 api:
3 build: ./api
4 ports:
5 - "8080:8080"
6 web:
7 build: ./web
8 ports:
9 - "4200:80"
10 depends_on:
11 - api

The multi-stage build documentation covers targeted builds ( --target build ) and stage sharing, both handy for isolating a test stage in the CI pipeline.

A production image should contain only what runs. Multi-stage builds make that discipline free: the SDK stays in the build stage, never in what you deploy .
super-dev — portfolio.app
// More in DEVOPS
>_
A GitHub Actions → Azure CI/CD pipeline, from scratch
8 min • 1.9k reads
.NET observability with OpenTelemetry
8 min • 1.6k reads