# ADR-002: One multi-stage Docker image serves the API and the SPA

Date: 2026-08-31
Status: Accepted

## In plain words

The site ships as one container image (Docker) that runs both the server code and the web pages. The image is built in stages, so the final one carries only the runtime and runs as a user without admin rights (non-root), and its health check asks the app a real question.

What that is worth: a developer runs the whole site with one command and can explain every line of the build, and the organization ships an image with no build tools in it, which leaves less to attack.

## Context

The application is a .NET 10 minimal API plus a Vite React SPA. Development
happens on Windows; the container must run on Linux hosts. The Dockerfile is a
portfolio artifact: every line has to be explainable in an interview.

## Decision

A single image built in three stages:

1. node:22-alpine builds the SPA with `npm ci` (deterministic, lockfile-driven),
   manifests copied before source so dependency restore caches independently.
2. dotnet/sdk:10.0 publishes the API in Release. The TargetFramework is read
   out of the csproj at build time rather than hard-coded, and the build fails
   loudly if it cannot be resolved.
3. dotnet/aspnet:10.0 is the final runtime stage: no SDK, no compilers. The API
   serves the SPA (static files plus MapFallbackToFile("index.html") mapped
   after the API routes, so deep links work and /api is never swallowed).

Runtime facts: port 8080 via ASPNETCORE_URLS and EXPOSE; a HEALTHCHECK curls
/api/facets so health means "answering real traffic", not "process exists";
the container runs as the aspnet image's built-in non-root `app` user, with
file ownership set per-COPY via --chown instead of a duplicate chown layer.
README.md, docs/ and data/ are copied into the image because the app serves
its documentation from the About menu and loads the dataset at runtime.
A .dockerignore keeps node_modules, bin, obj and .git out of the build context.

## Alternatives considered

- Two containers (nginx for the SPA, the API behind it) with compose or an
  ingress. The standard microservice shape, rejected here: one process to
  run, one origin to front, zero CORS surface, and the API already owns
  static serving. Right answer at larger scale, unnecessary overhead at this
  one.
- Shipping the SDK image as the final stage. Rejected: size and attack
  surface; the runtime image carries no toolchain.
- Creating a custom non-root user in the Dockerfile. Rejected after it
  collided with the base image: aspnet ships a built-in `app` user (APP_UID)
  for exactly this purpose, and using it is the current best practice.

## Consequences

- Roughly 380 MB on disk, of which the application layers are about 17 MB on
  top of Microsoft's runtime image.
- Layer caching behaves predictably: editing one C# file rebuilds only the
  publish and final-stage copies; editing package.json rebuilds only the npm
  layers.
- Visitors run the whole thing with `npm run docker` and stop it with
  `npm run docker:stop`.

## Where it sits

No ring applies here: the record is a packaging decision about how the Dockerfile builds one image for the API and the SPA, and SOLID has nothing to say about it.

## Files

- [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/main/Dockerfile): the three stages, each shown live below.
- [`.dockerignore`](https://github.com/SteveStout/TheYard/blob/main/.dockerignore): what never enters the build context.
- [`api/TheYard.Api/Program.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Program.cs): the one process the image runs.
- [`api/TheYard.Api/Composition/RequestPipeline.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/RequestPipeline.cs) and [`api/TheYard.Api/Composition/SpaRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/SpaRegistration.cs): serving the
  built SPA from wwwroot, and the fallback route.
- [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy.yml): the build that passes the two
  provenance arguments and pushes the image (ADR: The deploy pipeline).

The frontend build stage:

```dockerfile
# Stage 1: build the frontend in a Node environment.
FROM mirror.gcr.io/library/node:24-alpine AS frontend-build
# This gives us a standard Node toolchain for Vite and keeps the build dependencies isolated from the final runtime image.
WORKDIR /src

# Copy only the package manifests first so dependency restore can be cached independently from the app source.
COPY package*.json ./
# `npm ci` is deterministic because it uses the existing lockfile and is the correct choice for container builds.
RUN npm ci

# Copy the frontend configuration and source after dependencies are restored so a source-only change does not invalidate the restore cache.
COPY index.html ./
COPY tsconfig.json ./
COPY tsconfig.app.json ./
COPY tsconfig.node.json ./
COPY vite.config.ts ./
COPY src ./src
# Vite copies everything in public/ to the root of the built site untouched, so
# this is where robots.txt, sitemap.xml and the link preview image come from.
# It is easy to leave out, and leaving it out fails in the one place nobody
# looks: the built site loses those files while every local check stays green,
# because locally the folder is simply there (ADR: The second manifest).
COPY public ./public

# Build the production bundle that will be served by the ASP.NET host.
RUN npm run build

# #region stamp-the-build
# Two things the built site learns only here. The sitemap's /about entry takes
# the day this image was built as its lastmod, in place of the placeholder
# public/sitemap.xml carries; APP_VERSION is declared first so every version
# runs this step again rather than keeping a cached day. And Search Console's
# HTML-tag check: GOOGLE_SITE_VERIFICATION is a repository variable, public by
# design, and when it is set its meta tag replaces the comment index.html keeps
# for it. A token is letters, digits, underscores and hyphens and nothing else,
# so anything else stops the build rather than reaching the page.
ARG APP_VERSION=dev
ARG GOOGLE_SITE_VERIFICATION=
RUN set -e; \
    sed -i "s/__BUILD_DATE__/$(date -u +%Y-%m-%d)/" dist/sitemap.xml; \
    if grep -q "__BUILD_DATE__" dist/sitemap.xml; then echo "the sitemap kept its placeholder" >&2; exit 1; fi; \
    if [ -n "${GOOGLE_SITE_VERIFICATION}" ]; then \
      case "${GOOGLE_SITE_VERIFICATION}" in *[!A-Za-z0-9_-]*) echo "GOOGLE_SITE_VERIFICATION holds a character no token has" >&2; exit 1;; esac; \
      sed -i "s|<!-- google-site-verification -->|<meta name=\"google-site-verification\" content=\"${GOOGLE_SITE_VERIFICATION}\" />|" dist/index.html; \
      grep -q "name=\"google-site-verification\"" dist/index.html; \
    fi; \
    echo "stamped: $(grep -o '<lastmod>[^<]*</lastmod>' dist/sitemap.xml) for ${APP_VERSION}"
# #endregion stamp-the-build
```
*Live from [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/1d6b50b/Dockerfile#L4-L51), region `frontend-build`, read from this build at 1d6b50b.*

The API publish stage:

```dockerfile
# Stage 2: publish the .NET API in Release mode, compiled ahead of time. ReadyToRun turns this app's own assemblies and
# its packages (EF Core, the Cosmos DB SDK, Identity) into native code for linux-x64 at build time, beside the IL, so a
# new container starts by running code instead of compiling it on the plan's one shared core; the runtime still
# recompiles the methods that turn out hot at full optimization, with profile-guided optimization on, so nothing is
# given up once the process has warmed. The framework itself already ships compiled this way in the aspnet image.
# The cost is a larger image and a longer build, which Steve ruled do not count against it (7 October: "we worry more
# about end performance"). Native AOT was not taken: EF Core, the Cosmos DB SDK and the reflection the JSON and
# OpenAPI paths use do not support it (ADR: Compiled before it ships).
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS api-publish
# We use the SDK image here because publishing requires a toolchain that can compile and package the ASP.NET app.
WORKDIR /src

# Copy the solution and project files first so restore is cached separately from the rest of the source tree.
COPY api/TheYard.slnx ./api/
COPY api/TheYard.Data/TheYard.Data.csproj ./api/TheYard.Data/
COPY api/TheYard.Domain/TheYard.Domain.csproj ./api/TheYard.Domain/
COPY api/TheYard.Application/TheYard.Application.csproj ./api/TheYard.Application/
COPY api/TheYard.Infrastructure/TheYard.Infrastructure.csproj ./api/TheYard.Infrastructure/
COPY api/TheYard.Infrastructure.Cosmos/TheYard.Infrastructure.Cosmos.csproj ./api/TheYard.Infrastructure.Cosmos/
COPY api/TheYard.Experiment/TheYard.Experiment.csproj ./api/TheYard.Experiment/
COPY api/TheYard.Migrations.Sqlite/TheYard.Migrations.Sqlite.csproj ./api/TheYard.Migrations.Sqlite/
# The SQL project is in the solution, so restore needs to see it. It is not
# published into the image: the schema is deployed by SqlPackage, and this
# container has no rights to change it (ADR: Data first, and the database in
# source control).
COPY api/TheYard.Database/TheYard.Database.sqlproj ./api/TheYard.Database/
COPY api/TheYard.Api/TheYard.Api.csproj ./api/TheYard.Api/
COPY api/TheYard.Tests/TheYard.Tests.csproj ./api/TheYard.Tests/
# This restore is intentionally separate from source-copy so incremental Docker builds stay fast.
RUN dotnet restore api/TheYard.slnx
# And the API once more for the one platform it runs on, with ReadyToRun asked for, which is what makes
# restore fetch the ahead-of-time compiler for linux-x64 (ADR: Compiled before it ships).
RUN dotnet restore api/TheYard.Api/TheYard.Api.csproj -r linux-x64 -p:PublishReadyToRun=true

# Copy the rest of the backend source after restore so code changes do not force a dependency re-restore.
COPY api ./api

# Read the target framework from the project files instead of hard-coding it; the repo is currently on net10.0.
RUN set -eu; \
    TARGET_FRAMEWORK="$(grep -R -h -m1 --include='*.csproj' -Eo '<TargetFramework>[^<]+</TargetFramework>' /src/api | sed -E 's#<TargetFramework>([^<]+)</TargetFramework>#\1#' | head -n 1)"; \
    [ -n "${TARGET_FRAMEWORK}" ] || { echo "No TargetFramework found under /src/api" >&2; exit 1; }; \
    echo "Using target framework: ${TARGET_FRAMEWORK}"; \
    dotnet publish api/TheYard.Api/TheYard.Api.csproj -c Release --no-restore -f "${TARGET_FRAMEWORK}" \
        -r linux-x64 --self-contained false -p:PublishReadyToRun=true -o /app/publish
```
*Live from [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/1d6b50b/Dockerfile#L55-L98), region `api-publish`, read from this build at 1d6b50b.*

The runtime stage, non-root, with the health check and the sources the live
samples read (ADR: Live code samples):

```dockerfile
# Stage 3: final runtime image.
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
# The runtime image is the correct final stage for a production container because it does not include the SDK or compilers.
WORKDIR /app

# Install curl so the runtime can run a real HTTP healthcheck without pulling in the SDK image.
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*

# The aspnet base image ships a built-in non-root user named 'app' (APP_UID) for exactly this purpose;
# creating another one collides with it, so the built-in user is used instead.
# The published app and static frontend content need to be owned by the runtime user.
COPY --chown=app:app --from=api-publish /app/publish/ /app/
# The API reads project docs and the dataset from the repo root at runtime, so they must be present in the container.
COPY --chown=app:app README.md ./
COPY --chown=app:app docs ./docs
COPY --chown=app:app data ./data
COPY --chown=app:app infra ./infra
# Live code samples (ADR-014): the docs endpoints read these at request time. Sources only;
# wwwroot and the photo set already arrive through the publish above.
COPY --chown=app:app src ./src
COPY --chown=app:app .github/workflows ./.github/workflows
COPY --chown=app:app .vscode ./.vscode
COPY --chown=app:app api/TheYard.slnx ./api/
COPY --chown=app:app api/TheYard.Api/*.cs api/TheYard.Api/*.csproj ./api/TheYard.Api/
COPY --chown=app:app api/TheYard.Api/Composition/*.cs ./api/TheYard.Api/Composition/
COPY --chown=app:app api/TheYard.Api/Endpoints/*.cs ./api/TheYard.Api/Endpoints/
COPY --chown=app:app api/TheYard.Application/*.cs api/TheYard.Application/*.csproj ./api/TheYard.Application/
COPY --chown=app:app api/TheYard.Data/*.cs api/TheYard.Data/*.csproj ./api/TheYard.Data/
COPY --chown=app:app api/TheYard.Domain/*.cs api/TheYard.Domain/*.csproj ./api/TheYard.Domain/
COPY --chown=app:app api/TheYard.Infrastructure/*.cs api/TheYard.Infrastructure/*.csproj ./api/TheYard.Infrastructure/
COPY --chown=app:app api/TheYard.Infrastructure.Cosmos/*.cs api/TheYard.Infrastructure.Cosmos/*.csproj ./api/TheYard.Infrastructure.Cosmos/
COPY --chown=app:app api/TheYard.Experiment/*.cs api/TheYard.Experiment/*.csproj ./api/TheYard.Experiment/
COPY --chown=app:app api/TheYard.Migrations.Sqlite/*.cs api/TheYard.Migrations.Sqlite/*.csproj ./api/TheYard.Migrations.Sqlite/
# The DDL, so the records can show the schema they describe (ADR-014).
COPY --chown=app:app api/TheYard.Database ./api/TheYard.Database
COPY --chown=app:app api/TheYard.Tests/*.cs api/TheYard.Tests/*.csproj ./api/TheYard.Tests/
# The rest of what the records show live (ADR-017, the references pass): the edge,
# the end-to-end specs, and the root files the build and the tests are configured by.
COPY --chown=app:app Dockerfile netlify.toml playwright.config.ts vite.config.ts package.json index.html tsconfig.json tsconfig.app.json tsconfig.node.json .editorconfig ./
COPY --chown=app:app edge ./edge
COPY --chown=app:app tests ./tests
# The measuring script and the dataset tooling, shown by the records that used them (ADR-014, the
# coverage addendum): a live block can only show a file that is in the image, and a test holds this
# list to every path the documents name.
COPY --chown=app:app scripts ./scripts
COPY --chown=app:app render ./render
# One file from the sample beside this app: its architecture tests, which the onion practices page
# shows whole. Only that file, because the rest of the sample is served by its own site.
COPY --chown=app:app samples/theshed/tests/TestProject.Tests/OnionTests.cs ./samples/theshed/tests/TestProject.Tests/
# The built frontend bundle is copied into wwwroot so the ASP.NET API can serve it and provide SPA fallback routing.
COPY --chown=app:app --from=frontend-build /src/dist/ /app/wwwroot/

# Build provenance, baked in at image build so the running container can report
# exactly which build it is. The API serves these at /api/version and the page
# footer renders them; the ship pipeline passes both arguments (ADR-005).
# #region build-args
# Provenance, baked in at build time: the running container can then tell you
# exactly which version and commit it is (the page footer and /api/version read
# these). Defaults keep a local `docker build` working without arguments.
ARG APP_VERSION=dev
ARG APP_COMMIT=local
ENV APP_VERSION=${APP_VERSION}
ENV APP_COMMIT=${APP_COMMIT}
# #endregion build-args

# Set the runtime URL to port 8080, which matches the container runtime conventions we want for a single-process app.
ENV ASPNETCORE_URLS=http://+:8080
# Expose the application port so orchestrators and humans know which port to publish and probe.
EXPOSE 8080

# Healthcheck against a real API endpoint to confirm the app is accepting traffic, not just that the process is alive.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://localhost:8080/healthz || exit 1

# Somewhere for the runtime user to write (ADR: The relational store). /app
# itself is created by WORKDIR and owned by root, so the SQLite file gets a
# directory of its own that the app user owns. Nothing is mounted here: the
# database lives and dies with the container, which the record is explicit
# about, and which is why the catalogue is seeded on every first boot.
RUN mkdir -p /app/state && chown app:app /app/state
ENV ConnectionStrings__Yard="Data Source=/app/state/yard.db"

# Run as a non-root user to reduce attack surface and comply with container hardening best practices.
USER app

# Start the hosted API; it serves both the API endpoints and the SPA shell.
ENTRYPOINT ["dotnet", "TheYard.Api.dll"]
```
*Live from [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/1d6b50b/Dockerfile#L102-L187), region `runtime`, read from this build at 1d6b50b.*

## Addendum, 2026-10-03: the build stage is Node 24

The build stage moved from `node:22-alpine` to `node:24-alpine`, and CI from Node 22 to Node 24, because 22 is in maintenance and 24 is the active long-term release (ADR: Technology versions). Nothing else in the stage changed.

## Addendum, 2026-10-09 (1.0.3.109): the node image comes from a mirror that needs no account

Deploy #292 for 1.0.3.108 failed in two seconds at Build and push, and twice more when re-run: Docker Hub answered the metadata request for `node:24-alpine` from GitHub's runner with 429 Too Many Requests, and once with a 504 from its token service. Nothing in the commit; the anonymous pull allowance is shared by every runner on the address, and the two .NET images come from Microsoft's registry, which has no such limit. The three Node stages, the site's build stage and the rendering service's build and run stages, now pull the same official image from AWS's public mirror of it, `public.ecr.aws/docker/library/node:24-alpine`, which Docker publishes there itself, needs no account and no secret, and is not metered that way. Read from the build machine before the change: the mirror answers the tag with the image index, status 200. A signed-in pull from Docker Hub would have raised the allowance instead, at the price of a Docker account and a secret in the pipeline, which the rules refuse. `TechnologyVersionsTests` holds both Dockerfiles to the mirror's address and the Node version, and `DockerBuildInputsTests` finds the frontend stage by it.

## Addendum, 2026-10-15 (1.0.3.135): the second mirror

On 15 October Deploy Render and Deploy each failed twice, every time in under thirty seconds at Build and push: AWS's public mirror answered the manifest request for `node:24-alpine` with 429 Too Many Requests, "Data limit exceeded". That mirror has an allowance per address for anonymous pulls, and GitHub's runners share their addresses, so a quiet repository can find it already spent. A re-run went through each time, which is luck, not a fix. The three Node stages now pull the same image from Google's mirror of Docker Hub, `mirror.gcr.io/library/node:24-alpine`, which also needs no account and no secret. Read before the change: the mirror answers the tag with the image index, status 200, digest `sha256:44fce696...`, the same digest AWS's mirror served, so the bytes in the build do not change. The .NET stages stay on Microsoft's registry. `TechnologyVersionsTests` holds both Dockerfiles to the new address and `DockerBuildInputsTests` finds the frontend stage by it.
