Files
agatha 1b3468b72d 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>
2026-05-07 20:18:55 +00:00

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 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

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