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>
This commit is contained in:
152
specs/011-ui-prod-dockerfile/plan.md
Normal file
152
specs/011-ui-prod-dockerfile/plan.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user