# Research: Production-Grade UI Container Image ## Decision 1: Build-stage base image **Decision**: `node:22-slim` **Rationale**: Matches the version in the existing dev `ui/Dockerfile`. Slim variant reduces the builder layer size and attack surface relative to the full Debian image. **Alternatives considered**: `node:22-alpine` — lighter, but can introduce musl/glibc compatibility issues with some native npm packages; `node:22-bookworm-slim` — functionally equivalent to `node:22-slim`, same image. ## Decision 2: Runtime base image **Decision**: `nginxinc/nginx-unprivileged:alpine` **Rationale**: Runs fully as a non-root user on port 8080 out of the box — no manual user creation or privilege workarounds required. Alpine-based keeps the final image small. The official `nginx:alpine` image requires the master process to run as root to bind port 80; `nginx-unprivileged` avoids this by binding to 8080 instead. **Alternatives considered**: - `nginx:alpine` — master process must be root (violates FR-005); workers run as `nginx` user but `id -u` inside container still shows 0 for PID 1. - `caddy:alpine` — also supports non-root but adds Caddy's Go runtime footprint unnecessarily for pure static serving. ## Decision 3: Container port **Decision**: Expose port `8080` in the container; external orchestrators (docker-compose, Kubernetes ingress) map it to port 80 or 4200 as needed. **Rationale**: `nginxinc/nginx-unprivileged` defaults to port 8080; deviating would require overriding nginx config with no benefit. Port remapping is standard practice — containers should not run as root just to bind to a privileged port. **Alternatives considered**: Running nginx on port 80 requires either root or Linux capabilities (`CAP_NET_BIND_SERVICE`), both of which increase the attack surface. ## Decision 4: Angular build output directory **Decision**: COPY `dist/reactbin-ui/browser/` into the nginx document root. **Rationale**: The Angular 19 `@angular-devkit/build-angular:application` builder (esbuild-based) places browser assets in `dist/{projectName}/browser/` — confirmed by inspecting the existing `dist/reactbin-ui/browser/` directory in the repo. The parent `dist/reactbin-ui/` also contains `prerendered-routes.json` and `3rdpartylicenses.txt` which must not be served as the web root. **Alternatives considered**: Serving from `dist/reactbin-ui/` directly — would expose the `3rdpartylicenses.txt` file at the root and include the prerendering metadata file. ## Decision 5: Dependency install command **Decision**: `npm ci` (not `npm install`) **Rationale**: `npm ci` installs exactly what `package-lock.json` specifies — reproducible, faster on CI, and fails loudly on lockfile mismatches. All dependencies (including `devDependencies`) are needed in the build stage because Angular CLI and build tools are `devDependencies`. **Alternatives considered**: `npm install` — non-deterministic across environments; `npm install --omit=dev` — would break the Angular build since `@angular/cli` is a devDependency. ## Decision 6: Layer cache strategy **Decision**: Two COPY layers — lockfiles first, then source. ``` COPY package.json package-lock.json ./ # invalidated only on dep changes RUN npm ci # expensive step, cached when lockfiles unchanged COPY . . # invalidated on every source change RUN npm run build ``` **Rationale**: Mirrors the proven pattern used in the API's `Dockerfile.prod`. Dependency installation (30s–2min) is cached independently from source compilation. **Alternatives considered**: Single COPY of all source — trivial source changes would always re-run `npm ci`. ## Decision 7: SPA routing **Decision**: nginx `try_files $uri $uri/ /index.html` fallback in a custom `nginx.conf`. **Rationale**: Angular is a single-page application. All non-asset routes (e.g., `/library`, `/tags`, `/login`) must return `index.html` so Angular's router can handle them client-side. Without this, direct navigation to any deep link returns 404. **Alternatives considered**: Redirect to `/` — would break deep linking; returning 404 — breaks client-side routing entirely. ## Decision 8: Cache-control headers **Decision**: Long-lived `Cache-Control: public, max-age=31536000, immutable` for fingerprinted JS/CSS/font assets; `Cache-Control: no-store` for `index.html`. **Rationale**: Angular's production build fingerprints all bundles (e.g., `main.a1b2c3d4.js`). These are safe to cache indefinitely. `index.html` is never fingerprinted and must always be fresh so users pick up new deployments. **Alternatives considered**: No cache-control headers — acceptable for MVP but fails FR-008. ## Decision 9: Health check probe **Decision**: Use `wget -qO- http://localhost:8080/` as the HEALTHCHECK command (no `curl` in `nginx-unprivileged:alpine`). **Rationale**: The `nginxinc/nginx-unprivileged:alpine` image is minimal and does not include `curl`. `wget` is available in Alpine. The health check tests that nginx is accepting connections and returning the app shell. **Alternatives considered**: Installing `curl` via `apk add` — adds package manager overhead and unnecessary tooling to the runtime image. ## Decision 10: TDD verification approach **Decision**: Shell script `ui/tests/build/verify_production_image.sh` mirrors the approach used for the API in feature 010. **Rationale**: There is no pytest equivalent for Docker build artifacts. A shell script that fails because `Dockerfile.prod` does not exist satisfies §5.1 TDD (the script is the failing test; writing the Dockerfile turns it green). **Alternatives considered**: No TDD — violates §5.1; a Python test with subprocess — overkill when a shell script is simpler and already proven.