From 649194b174c4f9b029a8a6dc6cc000c3f88bc30a Mon Sep 17 00:00:00 2001 From: knacky Date: Fri, 22 May 2026 19:59:09 +0200 Subject: [PATCH] chore(frontend): add multi-stage Dockerfile + nginx SPA config Production image for the frontend dist. Stage 1 (build): node:22-alpine, `npm ci --ignore-scripts` from the committed lockfile, `npm run build`. Output lands in /app/dist. Stage 2 (runtime): docker.io/nginxinc/nginx-unprivileged:alpine. - Upstream-maintained variant that runs as the nginx user (uid 101) out of the box. /var/cache/nginx and /var/run/nginx are pre-owned, no chown gymnastics needed in our layer. Vanilla nginx:alpine fails at startup as non-root because client_temp mkdir is denied. - Listens on 8080 (non-privileged port, matches the unprivileged variant convention). - nginx.conf serves /usr/share/nginx/html with SPA `try_files` fallback for client-side routing, long-cache headers on /assets/ (Vite hashed bundles), a plaintext /healthz endpoint for Caddy / Prometheus blackbox, and server_tokens off. .dockerignore excludes node_modules, dist, .vite, coverage, playwright-report, .env*, .git, editor dirs. Keeps .env.example. Smoke local validated with `podman build -t mimic-frontend:smoke .` and `podman run -p 127.0.0.1:18080:8080`: /healthz -> 200 "ok" / -> 200 index.html (508 B) /spa/x -> 200 (SPA fallback) /assets -> Cache-Control: max-age=31536000, public, immutable --- frontend/.dockerignore | 22 ++++++++++++++++++++++ frontend/Dockerfile | 37 +++++++++++++++++++++++++++++++++++++ frontend/nginx.conf | 30 ++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..bfe9c4d --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,22 @@ +node_modules +dist +.vite +coverage +playwright-report +test-results +.eslintcache + +# Editor / OS +.DS_Store +.idea +.vscode + +# Env (never bake into image) +.env +.env.* +!.env.example + +# Git internals +.git +.gitignore +.gitattributes diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..96ef1c9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1.7 + +# --- Stage 1: build -------------------------------------------------------- +FROM node:22-alpine AS build + +ENV CI=true \ + npm_config_audit=false \ + npm_config_fund=false + +WORKDIR /app + +# Reproducible install: lockfile only, no scripts at install time. +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +# App sources. +COPY . . + +# Production build → /app/dist +RUN npm run build + +# --- Stage 2: runtime ------------------------------------------------------ +# nginxinc/nginx-unprivileged is the upstream-maintained variant that runs +# nginx as a non-root user out of the box (no chown gymnastics, /var/cache +# /var/run/nginx are owned by uid 101). It already listens on 8080. +FROM docker.io/nginxinc/nginx-unprivileged:alpine + +# Minimal SPA serving config: static dist + try_files SPA fallback. +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Static assets owned by the nginx user (uid 101 in this image). +COPY --from=build --chown=101:101 /app/dist /usr/share/nginx/html + +EXPOSE 8080 + +# Foreground nginx so the container lifecycle matches. +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..94395bb --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA routing: every unknown path falls back to index.html. + location / { + try_files $uri $uri/ /index.html; + } + + # Long-cache hashed asset bundles emitted by Vite under /assets/. + location /assets/ { + access_log off; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health endpoint scraped by Caddy / Prometheus blackbox. + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # No directory listing, no server tokens. + autoindex off; + server_tokens off; +}