# Implementation Plan: Production-Grade UI Container Image **Branch**: `011-ui-prod-dockerfile` | **Date**: 2026-05-07 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from `specs/011-ui-prod-dockerfile/spec.md` ## Summary Build a production-grade multi-stage Docker image for the Angular UI. A `node:22-slim` build stage compiles the Angular app into static assets; an `nginxinc/nginx-unprivileged:alpine` runtime stage serves those assets on port 8080 as a non-root user with SPA fallback routing, long-lived cache headers for fingerprinted bundles, and clean SIGTERM handling. The image is verified by a TDD shell script that covers all three user stories (reliable service, security, build caching) in one `make verify-ui-prod` run. ## Technical Context **Language/Version**: Node.js 22 (build stage); no runtime language in the final image **Primary Dependencies**: Angular CLI 19 (`npm run build`); nginx-unprivileged (runtime web server) **Storage**: None — container serves pre-compiled static files **Testing**: `ui/tests/build/verify_production_image.sh` (shell script TDD artefact, same pattern as `api/tests/build/verify_production_image.sh`) **Target Platform**: Linux container (amd64); Docker 23+ with BuildKit enabled (default); `--mount=type=cache` used for npm cache layer **Project Type**: Static file server (SPA) **Performance Goals**: Cold build < 3 minutes; warm (source-only) rebuild < 30 seconds; health check response < 500ms **Constraints**: Non-root process (UID ≠ 0); Node.js absent from runtime image; no secrets in image layers **Scale/Scope**: Single container; no horizontal scaling concerns at this stage ## Constitution Check ### Pre-research gates | Principle | Requirement | Status | |-----------|-------------|--------| | §5.1 TDD | Failing test (verify script) must exist before `Dockerfile.prod` | ✅ Plan includes TDD-first task ordering | | §5.3 Tests next to code | `ui/tests/build/` mirrors `api/tests/build/` | ✅ Correct location | | §5.4 CI before done | All tasks marked done only after verify passes | ✅ Enforced in task ordering | | §7.1 One-command start | `docker compose up` must still work | ✅ Only adds prod Dockerfile; dev Dockerfile unchanged | | §7.2 Env config | No hardcoded credentials in Dockerfile | ✅ No runtime env vars needed; build-time config via Angular environment files | | §7.3 Linting | shellcheck on verify script | ✅ T011 in task plan | | §8 Scope | Server-side rendering, OIDC, multi-user — not addressed | ✅ Spec scoped to static asset serving only | **No violations. All gates pass.** ### Post-design re-check Same gates apply. No design decisions introduced in Phase 1 conflict with the constitution. ## Project Structure ### Documentation (this feature) ```text specs/011-ui-prod-dockerfile/ ├── plan.md ← this file ├── research.md ← technology decisions (10 decisions) ├── contracts/ │ └── container.md ← container interface contract ├── quickstart.md ← build and verify scenarios └── tasks.md ← generated by /speckit-tasks ``` ### Source Code Changes ```text ui/ ├── Dockerfile.prod ← NEW (multi-stage production build) ├── nginx.conf ← NEW (SPA routing + cache headers) ├── .dockerignore ← NEW (does not exist yet; created for production build) └── tests/ └── build/ ├── .gitkeep ← NEW (track directory in git) └── verify_production_image.sh ← NEW (TDD verification script) Makefile ← MODIFIED (add build-ui-prod, verify-ui-prod targets) ``` ## Dockerfile Design ### Stage 1 — Builder (`node:22-slim`) ``` COPY package.json package-lock.json ./ # layer: deps (cached until lockfile changes) RUN --mount=type=cache,target=/root/.npm npm ci # reproducible install; npm cache mounted COPY . . # layer: source (invalidated on every change) RUN npm run build # ng build --configuration production ``` Output of `npm run build`: `dist/reactbin-ui/browser/` (confirmed: Angular 19 application builder creates `browser/` subdirectory under `outputPath`). ### Stage 2 — Runtime (`nginxinc/nginx-unprivileged:alpine`) - Runs as non-root by design (no manual `useradd` needed) - Listens on port 8080 - `COPY --from=builder /app/dist/reactbin-ui/browser /usr/share/nginx/html` - `COPY nginx.conf /etc/nginx/conf.d/default.conf` - HEALTHCHECK via `wget` (curl not present in Alpine nginx-unprivileged) - No CMD override needed — the base image entrypoint starts nginx ### nginx.conf ```nginx server { listen 8080; root /usr/share/nginx/html; index index.html; # SPA fallback — unmatched paths return app shell location / { try_files $uri $uri/ /index.html; } # Long-lived cache for fingerprinted assets location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$ { expires 1y; add_header Cache-Control "public, immutable"; } # Never cache the entry point location = /index.html { add_header Cache-Control "no-store, no-cache, must-revalidate"; } } ``` ## Verification Script Design (`ui/tests/build/verify_production_image.sh`) Mirrors `api/tests/build/verify_production_image.sh` structure: | Check | Story | Description | |-------|-------|-------------| | Build | US1 | `docker build -f ui/Dockerfile.prod ui/` succeeds | | Health endpoint | US1 | `wget -q http://localhost:18080/` returns 200 within 30s | | SPA routing | US1 | `curl http://localhost:18080/library` returns 200 | | Graceful shutdown | US1 | `docker stop` → exit code 0 | | Non-root user | US2 | `docker exec id -u` ≠ 0 | | Node.js absent | US2 | `docker run node --version` exits non-zero | | No secrets in history | US2 | `docker history --no-trunc` contains no secret-like strings | | Dep layer cache hit | US3 | `touch ui/src/app/app.component.ts` + rebuild → output contains `CACHED` | ## Makefile Additions ```makefile build-ui-prod: docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest verify-ui-prod: bash ui/tests/build/verify_production_image.sh ``` ## Dependencies & Risks | Item | Risk | Mitigation | |------|------|------------| | `dist/reactbin-ui/browser/` path | If Angular changes the output directory structure in a future version, the COPY path breaks | Path is verified in research; a test build during verify catches drift | | `nginxinc/nginx-unprivileged` UID | UID may vary between image versions | Check is `UID ≠ 0`, not a specific UID value | | `wget` availability | Alpine images may change toolset | HEALTHCHECK is tested as part of US1 verify | | Port 18080 collision | Another process may use 18080 during verify | Acceptable risk for a dev-time test; port is not a system service |