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:
2026-05-07 20:18:55 +00:00
parent 12176471e1
commit 1b3468b72d
16 changed files with 885 additions and 3 deletions

View File

@@ -7,3 +7,5 @@ coverage/
.env.*
!.env.example
*.log
*.spec.ts
tests/

30
ui/Dockerfile.prod Normal file
View File

@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
# ════════════════════════════════════════════════
# Build stage: install deps and compile Angular app
# ════════════════════════════════════════════════
FROM node:22-slim AS builder
WORKDIR /app
# Layer cache split: deps only (changes rarely)
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Layer cache split: source (changes often)
COPY . .
RUN npm run build
# ════════════════════════════════════════════════
# Runtime stage: minimal nginx serving static assets
# ════════════════════════════════════════════════
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

22
ui/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
# SPA fallback: all unmatched paths → 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";
}
}

0
ui/tests/build/.gitkeep Normal file
View File

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# TDD verification script for ui/Dockerfile.prod
# Fails (red) if Dockerfile.prod does not exist or any check fails.
set -euo pipefail
IMAGE="reactbin-ui-prod:verify-$$"
IMAGE2="reactbin-ui-prod:verify-cache-$$"
APP_CONTAINER=""
cleanup() {
[ -n "$APP_CONTAINER" ] && docker rm -f "$APP_CONTAINER" 2>/dev/null || true
docker rmi "$IMAGE" 2>/dev/null || true
docker rmi "$IMAGE2" 2>/dev/null || true
}
trap cleanup EXIT
# ── US1 check 1: build ────────────────────────────────────────────────────────
echo "[verify] Building $IMAGE..."
docker build -f ui/Dockerfile.prod ui/ -t "$IMAGE"
echo "[verify] Build OK"
# ── US1 check 2: start container ──────────────────────────────────────────────
echo "[verify] Starting production container..."
APP_CONTAINER=$(docker run -d -p 18080:8080 "$IMAGE")
# ── US1 check 3: health endpoint ──────────────────────────────────────────────
echo "[verify] Polling health endpoint..."
for i in $(seq 1 30); do
if curl -sf http://localhost:18080/ > /dev/null; then break; fi
sleep 1
if [[ $i -eq 30 ]]; then echo "FAIL: health check timed out after 30s"; exit 1; fi
done
echo "[verify] Health check passed"
# ── US1 check 4: SPA routing ──────────────────────────────────────────────────
if ! curl -sf http://localhost:18080/library > /dev/null; then
echo "FAIL: SPA routing check failed (/library did not return 200)"; exit 1
fi
echo "[verify] SPA routing OK (/library → 200)"
# ── US2 check 1: non-root user ────────────────────────────────────────────────
UID_IN_CONTAINER=$(docker exec "$APP_CONTAINER" id -u)
if [[ "$UID_IN_CONTAINER" -eq 0 ]]; then
echo "FAIL: process running as root (UID 0)"; exit 1
fi
echo "[verify] Non-root user OK (UID $UID_IN_CONTAINER)"
# ── C1: stdout/stderr log capture ─────────────────────────────────────────────
LOGS=$(docker logs "$APP_CONTAINER" 2>&1)
if [[ -z "$LOGS" ]]; then
echo "FAIL: no output on stdout/stderr"; exit 1
fi
echo "[verify] Stdout logging OK"
# ── FR-008: cache-control headers on fingerprinted assets ─────────────────────
JS_FILE=$(docker run --rm "$IMAGE" ls /usr/share/nginx/html | grep -E '\.js$' | head -1)
if [[ -n "$JS_FILE" ]]; then
CACHE_HEADER=$(curl -sI "http://localhost:18080/${JS_FILE}" | grep -i "cache-control" || true)
if ! echo "$CACHE_HEADER" | grep -qi "immutable\|max-age=31536000"; then
echo "FAIL: cache-control header not set on fingerprinted asset ${JS_FILE}"; exit 1
fi
echo "[verify] Cache-Control header OK"
else
echo "[verify] Cache-Control header check skipped (no .js file found at root)"
fi
# ── US1 check 5: SIGTERM → exit 0 ────────────────────────────────────────────
docker stop "$APP_CONTAINER" > /dev/null
EXIT_CODE=$(docker wait "$APP_CONTAINER")
if [[ "$EXIT_CODE" -ne 0 ]]; then
echo "FAIL: non-zero exit code $EXIT_CODE after SIGTERM"; exit 1
fi
echo "[verify] Graceful shutdown OK (exit $EXIT_CODE)"
# ── US2 check 2: Node.js absent from runtime image ───────────────────────────
set +e
docker run --rm "$IMAGE" node --version 2>/dev/null
NODE_EXIT=$?
set -e
if [[ "$NODE_EXIT" -eq 0 ]]; then
echo "FAIL: node runtime found in production image"; exit 1
fi
echo "[verify] Node.js absent in runtime image OK"
# ── C2: no hardcoded secrets in image layers ─────────────────────────────────
if docker history --no-trunc "$IMAGE" 2>&1 | grep -qiE "(password|secret_key|api_key|token)"; then
echo "FAIL: potential secret found in image history"; exit 1
fi
echo "[verify] No secrets in image layers OK"
# ── US3: dep layer cached on source-only rebuild ─────────────────────────────
echo "[verify] Testing cache hit on source-only rebuild..."
touch ui/src/app/app.component.ts
BUILD2_OUTPUT=$(docker build --progress=plain -f ui/Dockerfile.prod ui/ -t "$IMAGE2" 2>&1)
if ! echo "$BUILD2_OUTPUT" | grep -q "CACHED"; then
echo "FAIL: dependency layer not reused on source-only rebuild"; exit 1
fi
echo "[verify] Dep layer cache hit confirmed (US3 OK)"
echo "[verify] All checks passed (US1 + US2 + US3)."