Files
reactbin/specs/011-ui-prod-dockerfile/research.md
agatha 1b3468b72d Feat: Add production-grade multi-stage container image for UI
Two-stage build (node:22-slim builder + nginxinc/nginx-unprivileged:alpine
runtime) with SPA fallback routing, long-lived cache headers for fingerprinted
assets, non-root user (UID 101), and no Node.js toolchain in runtime image
(82 MB vs 329 MB+ single-stage). Verified by ui/tests/build/verify_production_image.sh
covering build, health, SPA routing, non-root, stdout logging, cache-control
headers, SIGTERM exit 0, Node.js absent, secret-free layers, and dep-layer
cache hit. 102 integration tests still pass; shellcheck clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:18:55 +00:00

70 lines
5.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (30s2min) 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.