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>
13 KiB
Tasks: Production-Grade UI Container Image
Input: Design documents from specs/011-ui-prod-dockerfile/
Prerequisites: plan.md ✅, spec.md ✅, research.md ✅, contracts/container.md ✅, quickstart.md ✅
Tests: TDD is non-negotiable (§5.1). The "test" for a Docker build artefact is ui/tests/build/verify_production_image.sh, written before ui/Dockerfile.prod exists. Running the script immediately fails (red) because the build step cannot find the file; writing Dockerfile.prod turns it green.
Organization: Phase 1 sets up Makefile targets, .dockerignore, and supporting files; Phase 3 (US1) writes the verification script and the Dockerfile; Phase 4 (US2) extends the script with security checks; Phase 5 (US3) extends it with a cache-hit check; Phase 6 polishes.
Format: [ID] [P?] [Story] Description
- [P]: Can run in parallel with other [P] tasks in the same phase
- [Story]: Which user story this task belongs to
- Exact file paths included in every task description
Phase 1: Setup
-
T001 Add
build-ui-prodandverify-ui-prodtargets (and their.PHONYentries) to the rootMakefileat/workspace/Makefile:build-ui-prodrunsdocker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest;verify-ui-prodrunsbash ui/tests/build/verify_production_image.sh -
T002 Create
ui/.dockerignoreat/workspace/ui/.dockerignorewith the following exclusions (the file does not yet exist — create it fresh):.git/,node_modules/,dist/,.angular/,coverage/,*.spec.ts,.env,.env.*,!.env.example,tests/; these keep the build context transfer fast and prevent dev state from leaking into the production image -
T003 Create directory
ui/tests/build/at/workspace/ui/tests/build/withmkdir -pand add a.gitkeepso the directory is tracked in git
Checkpoint: Directory structure is ready; Makefile and .dockerignore are created.
Phase 2: Foundational
No blocking foundational prerequisites exist for this feature — the setup tasks in Phase 1 directly enable all user story phases. Phase 2 is intentionally omitted.
Phase 3: User Story 1 — UI Serves Reliably in Production (Priority: P1) 🎯 MVP
Goal: The container builds, starts, serves the health endpoint and SPA routes, and exits cleanly on SIGTERM.
Independent Test: make verify-ui-prod — passes when Dockerfile.prod and nginx.conf exist and all US1 checks pass.
Test for User Story 1 (TDD red — write first, confirm failure before T005)
- T004 [US1] Create
ui/tests/build/verify_production_image.shas an executable bash script (chmod +x) with#!/usr/bin/env bashandset -euo pipefail; the script MUST:- Set
IMAGE="reactbin-ui-prod:verify-$$"andIMAGE2="reactbin-ui-prod:verify-cache-$$"andAPP_CONTAINER=""; - Define a
cleanup()function that runsdocker rm -f "$APP_CONTAINER" 2>/dev/null || true,docker rmi "$IMAGE" 2>/dev/null || true, anddocker rmi "$IMAGE2" 2>/dev/null || true, then register it withtrap cleanup EXIT; - [US1 check 1 — build] Run
docker build -f ui/Dockerfile.prod ui/ -t "$IMAGE"— this is the line that fails red becauseui/Dockerfile.proddoes not yet exist; print[verify] Building $IMAGE...before and[verify] Build OKafter; - [US1 check 2 — start container] Start the production container:
APP_CONTAINER=$(docker run -d -p 18080:8080 "$IMAGE"); print[verify] Starting production container...; - [US1 check 3 — health endpoint] Poll
curl -sf http://localhost:18080/up to 30 × 1s, fail withFAIL: health check timed out after 30sif timeout; print[verify] Health check passedon success; - [US1 check 4 — SPA routing] Run
curl -sf http://localhost:18080/library > /dev/null; assert exit code is 0 (200 response); fail withFAIL: SPA routing check failed (/library did not return 200)if violated; print[verify] SPA routing OK (/library → 200); - [US1 check 5 — SIGTERM → exit 0] Run
docker stop "$APP_CONTAINER"(sends SIGTERM); captureEXIT_CODE=$(docker wait "$APP_CONTAINER"); assert"$EXIT_CODE" -eq 0, fail withFAIL: non-zero exit code $EXIT_CODE after SIGTERMotherwise; print[verify] Graceful shutdown OK (exit $EXIT_CODE); - Print
[verify] US1 checks passed.After writing the script, runmake verify-ui-prodand confirm it fails with a Docker build error (red state —ui/Dockerfile.proddoes not exist).
- Set
Implementation for User Story 1
-
T005 [US1] Create
ui/nginx.confat/workspace/ui/nginx.conf— an nginx server block that: listens on port8080; setsroot /usr/share/nginx/htmlandindex index.html; adds alocation /block withtry_files $uri $uri/ /index.htmlfor SPA fallback routing; adds alocation ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$block withexpires 1yandadd_header Cache-Control "public, immutable"for fingerprinted assets; adds alocation = /index.htmlblock withadd_header Cache-Control "no-store, no-cache, must-revalidate"so the entry point is never cached -
T006 [US1] Create
ui/Dockerfile.prodat/workspace/ui/Dockerfile.prod— a two-stage multi-stage build: Stage 1 (builder):FROM node:22-slim AS builder;WORKDIR /app;COPY package.json package-lock.json ./;RUN --mount=type=cache,target=/root/.npm npm ci;COPY . .;RUN npm run buildStage 2 (runtime):FROM nginxinc/nginx-unprivileged:alpine;COPY --from=builder /app/dist/reactbin-ui/browser /usr/share/nginx/html;COPY nginx.conf /etc/nginx/conf.d/default.conf;EXPOSE 8080;HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget -qO- http://localhost:8080/ || exit 1 -
T007 [US1] Verify TDD green for US1: run
make verify-ui-prodand confirm all five US1 checks pass — build OK, health endpoint returns 200, SPA routing returns 200, SIGTERM produces exit code 0, and[verify] US1 checks passed.is printed.
Checkpoint: US1 is complete. Production container builds, starts, serves traffic (including SPA routes), and shuts down gracefully.
Phase 4: User Story 2 — Minimal, Secure Container (Priority: P2)
Goal: The production image runs as non-root and contains no Node.js runtime, source, or embedded secrets.
Independent Test: US2 checks in make verify-ui-prod — the same script extended with non-root, node-absent, and secrets-free assertions.
Tests for User Story 2 (TDD extension — add checks, confirm they pass against existing Dockerfile.prod)
- T008 [US2] Extend
ui/tests/build/verify_production_image.shwith US2 checks inserted after the health/SPA/SIGTERM checks (before the finalUS1 checks passedline) and update the final success message to[verify] All checks passed (US1 + US2).: [US2 check 1 — non-root] Beforedocker stop, runUID_IN_CONTAINER=$(docker exec "$APP_CONTAINER" id -u); assert"$UID_IN_CONTAINER" -ne 0, fail withFAIL: process running as root (UID 0)if violated; print[verify] Non-root user OK (UID $UID_IN_CONTAINER); [C1 — stdout log capture] RunLOGS=$(docker logs "$APP_CONTAINER" 2>&1); assert"$LOGS"is non-empty, fail withFAIL: no output on stdout/stderrif empty; print[verify] Stdout logging OK; insert this check beforedocker stop; [US2 check 2 — Node.js absent] After SIGTERM cleanup, rundocker run --rm "$IMAGE" node --version 2>/dev/null; assert the exit code is non-zero (node not present in runtime image); if it returns 0, fail withFAIL: node runtime found in production image; print[verify] Node.js absent in runtime image OK; [C2 — no hardcoded secrets in layers] Rundocker history --no-trunc "$IMAGE" 2>&1; pipe throughgrep -qiE "(password|secret_key|api_key|token)"; assert zero matching lines; if any match, fail withFAIL: potential secret found in image history; print[verify] No secrets in image layers OK; [FR-008 — cache-control headers on assets] While APP_CONTAINER is running, find the first JS bundle filename:JS_FILE=$(docker run --rm "$IMAGE" ls /usr/share/nginx/html | grep -E '\.js$' | head -1); runcurl -sI "http://localhost:18080/${JS_FILE}"; assert the response containsCache-Controlwithimmutableormax-age=31536000, fail withFAIL: cache-control header not set on fingerprinted assetif absent; print[verify] Cache-Control header OK; Confirmmake verify-ui-prodpasses with the extended checks.
Checkpoint: US2 is verified. Image runs as a non-root user and contains no Node.js toolchain.
Phase 5: User Story 3 — Fast, Reproducible Builds (Priority: P3)
Goal: Rebuilding after a source-only change reuses the npm ci dependency layer from cache.
Independent Test: US3 check in make verify-ui-prod — a second build after touching a source file asserts the dep layer was cached.
Tests for User Story 3 (TDD extension)
-
T009 [US3] Extend
ui/tests/build/verify_production_image.shwith a US3 cache check appended after all other checks (before the final success line): [US3 check — dep layer cached on source-only rebuild] Print[verify] Testing cache hit on source-only rebuild...;touch ui/src/app/app.component.ts; captureBUILD2_OUTPUT=$(docker build --progress=plain -f ui/Dockerfile.prod ui/ -t "$IMAGE2" 2>&1)(the--progress=plainflag ensures consistentCACHEDoutput regardless of Docker version or TTY); assert the output contains the stringCACHED; if absent, fail withFAIL: dependency layer not reused on source-only rebuild; print[verify] Dep layer cache hit confirmed (US3 OK); Update the final success line to[verify] All checks passed (US1 + US2 + US3). -
T010 [US3] Verify TDD green for US3: run
make verify-ui-prodand confirm the full script passes including the cache check — the build output for the second image must containCACHED, and[verify] All checks passed (US1 + US2 + US3).must print.
Checkpoint: All three user stories are verified end-to-end by make verify-ui-prod.
Phase 6: Polish & Cross-Cutting Concerns
-
T011 Run
make test-integrationfrom/workspaceand confirm all 102 existing tests still pass — verifies that the new files (Makefile targets, ui/.dockerignore, ui/tests/build/) do not break the existing test Dockerfile build or any integration test (§5.4 regression gate) -
T012 Confirm image size reduction (SC-002): run
docker images reactbin-ui-prod:latest --format "{{.Size}}"and compare against a reference single-stage image built fromFROM node:22-slim+npm ci+npm run buildto confirm the production image is substantially smaller (expected >60% reduction); document the sizes in a comment or log line -
T013 Run
shellcheck ui/tests/build/verify_production_image.shand fix any violations (common: unquoted variables,[ ]vs[[ ]], missing--before arguments); also verifymake verify-ui-prodstill passes after any fixes
Dependencies & Execution Order
Phase Dependencies
- Phase 1 (Setup): No external dependencies — start immediately
- Phase 3 (US1): Depends on Phase 1 (Makefile + .dockerignore must exist before
make verify-ui-prodcan run) and directory must exist (T003) - Phase 4 (US2): Depends on Phase 3 (US1 script and Dockerfile must exist to extend)
- Phase 5 (US3): Depends on Phase 4 (full US2 script must exist to extend)
- Phase 6 (Polish): Depends on all prior phases; T011 before T012
Within Phase 3
- T004 before T005/T006 (write test script before writing the nginx config and Dockerfile)
- T005 and T006 can run in parallel (different files, no mutual dependency)
- T007 after T005 and T006 (verify green after both implementation files exist)
Execution Order Summary
Step 1: T001 ∥ T002 ∥ T003 (setup — parallel, different files)
Step 2: T004 (write verification script — TDD red)
Step 3: T005 ∥ T006 (write nginx.conf and Dockerfile.prod — parallel)
Step 4: T007 (verify US1 green)
Step 5: T008 (extend script with US2 checks, verify pass)
Step 6: T009 (extend script with US3 check)
Step 7: T010 (verify US3 green)
Step 8: T011 (make test-integration — regression gate)
Step 9: T012 (image size comparison — SC-002)
Step 10: T013 (shellcheck polish)
Implementation Strategy
MVP (US1 — reliable production run)
- Complete T001–T003 (setup)
- Complete T004–T007 (core: write script → write nginx.conf + Dockerfile → verify green)
- Validate:
make verify-ui-prodpasses;make test-integrationstill passes - US2 and US3 add explicit verification coverage for properties already implemented by the two-stage build
Incremental Delivery
- After Phase 3: Production image builds, starts, serves traffic with SPA routing — safe to deploy
- After Phase 4: Security properties (non-root, no Node.js runtime) are explicitly verified
- After Phase 5: Build efficiency (npm ci layer caching) is confirmed by automated check
- After Phase 6: Script is lint-clean, ready for CI integration