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>
5.7 KiB
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 asnginxuser butid -uinside 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.