# ADR: Linux containers over Windows

Status: accepted in practice since the first image; written down 2026-09-01.

## In plain words

The site's container runs on Linux even though the app is built on a Windows machine. Nothing in the app needs Windows, and the Linux image is a fraction of the size of a Windows one.

What that is worth: a developer gets one image that runs the same on the laptop as in production, and the organization moved the site to a new host without changing the build file (Dockerfile).

## Context

The application is built on a Windows machine, so Windows containers were
the default-looking choice. The Dockerfile went Linux instead: a
node:22-alpine build stage and the aspnet:10.0 Linux runtime. A week of
shipping has turned that instinct into reasons.

## Decision

Linux containers, everywhere the image runs.

## The reasons, as learned

- .NET 10 is fully cross-platform. Nothing in this application touches a
  Windows API, so Windows in the runtime would carry cost without buying
  anything.
- Size is a tax paid on every push and every pull. The Linux runtime image
  keeps the whole application under a few hundred megabytes; Windows base
  images are gigabytes before the first line of app code arrives. This
  registry gets fed over a home connection, and the container group pulls
  the image on every roll.
- The identity design requires it. The registry pull rides a user-assigned
  managed identity, and that capability belongs to Linux container groups
  on Azure Container Instances; Microsoft's own Q&A is a trail of Windows
  container groups failing at exactly this.
- The hardening came free. The Linux aspnet image ships a built-in
  non-root user, and the healthcheck is one curl line.
- One image works everywhere it needs to: Docker Desktop runs it on the
  Windows dev machine through WSL2, GitHub's ubuntu runners will build it
  in CI, and ACI runs it in production. Same bytes, three hosts.

## Consequences

- The dev machine needed WSL2, installed on provisioning day, a one-time
  cost.
- Anything Windows-specific that creeps into the runtime is refused by the
  container. That constraint is a feature.

## Addendum, 2026-09-20: the same image, on a different host

Superseded in part on 2026-09-20, as 1.0.0.156: the image no longer runs on Container Instances.
Both sites are web apps for containers on one Linux App Service plan (ADR: One plan, two sites).
The decision stands and did its job again: the same Linux image moved hosts with no change to the
Dockerfile, which is the reason the first record gives for having a container at all. One of the
reasons above moved with it. The registry pull still rides the user-assigned identity, and on App
Service that is two properties on the site, `acrUseManagedIdentityCreds` and the identity's client
id, rather than a capability of Linux container groups.

## Where it sits

No ring: choosing Linux for the container is a hosting decision about the base images, and the code's layering and SOLID are untouched by it.

## Files

- [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/main/Dockerfile): Linux base images in every stage; the runtime stage
  is shown live below (the other two are in ADR: Docker packaging).
- [`.github/workflows/ci.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/ci.yml) and
  [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy.yml): every job runs on
  `ubuntu-latest`, so the image is built where it runs.
- [`infra/aci-theyard.yaml`](https://github.com/SteveStout/TheYard/blob/main/infra/aci-theyard.yaml): the Linux container group that hosts it.

```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.
