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>
6.8 KiB
Implementation Plan: Production-Grade UI Container Image
Branch: 011-ui-prod-dockerfile | Date: 2026-05-07 | Spec: 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)
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
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
useraddneeded) - Listens on port 8080
COPY --from=builder /app/dist/reactbin-ui/browser /usr/share/nginx/htmlCOPY 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
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
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 |