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>
153 lines
6.8 KiB
Markdown
153 lines
6.8 KiB
Markdown
# 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 |
|