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

5.7 KiB
Raw Blame History

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.