Add container builds for the backend and the frontend

Backend: Alpine runtime without Chromium by default, opt-in Debian
scraper target with Playwright. Frontend: Vite build served by
nginx-unprivileged with the API upstream resolved at start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent 937690f486
commit 375bc64f2f
7 changed files with 452 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Build context = frontend/. Everything the Vite build does not read stays out.
node_modules/
dist/
coverage/
test-results/
playwright-report/
.vite/
.turbo/
# Tests and E2E are gated in CI, not in the image build.
tests/
e2e/
playwright.config.ts
vitest.config.ts
**/*.test.ts
**/*.test.tsx
# Local env / secrets
.env
.env.*
!.env.example
# Repo noise
.git/
.gitignore
.DS_Store
*.md
out.js
Dockerfile
.containerignore
.dockerignore
+1
View File
@@ -0,0 +1 @@
.containerignore
+90
View File
@@ -0,0 +1,90 @@
# syntax=docker/dockerfile:1.7
#
# Föräldraledigplannerare — frontend (Vite build, served by nginx-unprivileged).
#
# Build context: frontend/
#
# podman build -t foraldraledig-frontend frontend/
# podman run -p 8080:8080 -e API_UPSTREAM=http://backend:8000 foraldraledig-frontend
#
# ---------------------------------------------------------------------------
# NO BUILD-TIME API URL — AND NONE IS NEEDED
#
# src/services/api.ts uses `const BASE = '/api'`: every call is same-origin and
# relative. Nothing about the backend location is baked into the bundle by
# Vite, so the usual `import.meta.env.VITE_API_URL` placeholder-substitution
# dance does not apply here. Instead this image *is* the origin: nginx serves
# the static bundle and reverse-proxies /api to the backend, with the upstream
# taken from the ${API_UPSTREAM} env var at container start (nginx's own
# envsubst templating, /etc/nginx/templates). One knob, resolved at runtime,
# and the default points at a compose/pod service name rather than localhost.
#
# Caveat: nginx resolves the proxy_pass host once, at startup. If the backend
# name is not resolvable yet, nginx exits — start the backend first, or give
# the frontend a restart policy.
# ---------------------------------------------------------------------------
# ===========================================================================
# Stage 1 — build the static bundle
# ===========================================================================
FROM node:22-alpine AS build
WORKDIR /src
# Lockfile first: dependency installs stay cached across source edits.
COPY package.json package-lock.json ./
# `npm ci` = exact, reproducible install from package-lock.json.
RUN npm ci
COPY . .
# `npm run build` is `tsc && vite build` — the type check is part of the
# build, so a type error fails the image instead of shipping.
RUN npm run build
# ===========================================================================
# Stage 2 — runtime
# ===========================================================================
#
# Why nginxinc/nginx-unprivileged:
# * Already non-root (uid 101) and listens on 8080, so no capability juggling
# and no USER override needed to satisfy a restricted PodSecurity policy.
# * Alpine variant is ~20 MB, i.e. in the same class as Caddy/distroless
# while being the most boring, most audited static server in existence.
# * Ships the envsubst template entrypoint, which is exactly what the
# runtime ${API_UPSTREAM} substitution above needs — a distroless static
# binary would have meant hand-rolling that.
FROM nginxinc/nginx-unprivileged:1.29-alpine AS runtime
# Tag-pinned, not digest-pinned: rebuilds should pick up nginx/Alpine CVE
# fixes. The *content* served is already pinned by package-lock.json. Pin the
# digest in the deployment pipeline if immutability outranks patching.
ENV API_UPSTREAM=http://backend:8000
# Template, not a plain conf: the stock entrypoint runs envsubst over
# /etc/nginx/templates/*.template into /etc/nginx/conf.d/ on start.
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build --chown=101:101 /src/dist /usr/share/nginx/html
USER 101
EXPOSE 8080
# busybox wget — already present, no extra package.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"]
# The base image's ENTRYPOINT (docker-entrypoint.sh) execs nginx, so nginx is
# PID 1 and gets SIGTERM/SIGQUIT directly. Inherited CMD ["nginx", "-g",
# "daemon off;"] is correct as-is.
#
# Read-only root filesystem (verified working) — run with:
# --read-only --tmpfs /tmp --tmpfs /var/cache/nginx \
# --tmpfs /etc/nginx/conf.d:rw,mode=1777
# /etc/nginx/conf.d is where envsubst renders the template, so it must be
# writable BY UID 101 — podman's default tmpfs mode is not, and the entrypoint
# then silently skips the substitution and nginx serves no server block at all.
# mode=1777 is what makes it work. /tmp holds the pid file and proxy buffers.
+77
View File
@@ -0,0 +1,77 @@
# Rendered by the nginx-unprivileged entrypoint: envsubst expands ${API_UPSTREAM}
# (and only names that exist in the environment — nginx's own $uri/$host/etc.
# are left alone) into /etc/nginx/conf.d/default.conf at container start.
server {
# 8080, not 80: unprivileged nginx runs as uid 101 and cannot bind <1024.
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The bundle is already gzipped over the wire by this; Brotli would need a
# module the stock image does not carry, so gzip it is.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css application/javascript application/json
image/svg+xml application/manifest+json;
server_tokens off;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
# Container-level liveness. Served from memory, so a broken bundle or a
# read-only mount cannot make this lie.
location = /healthz {
access_log off;
# default_type, not `add_header Content-Type`: add_header appends, so
# the response would carry two Content-Type headers.
default_type text/plain;
return 200 "ok\n";
}
# Same-origin API. Because the browser only ever talks to this origin, the
# backend's CORS allow-list is not exercised at all in this deployment.
location /api/ {
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# The FK-scraper poll endpoints are long-ish; don't cut them at 60s.
proxy_read_timeout 120s;
proxy_buffering off;
}
# Vite emits content-hashed filenames under /assets, so they are immutable.
location /assets/ {
access_log off;
# `expires 1y` would emit its own Cache-Control alongside this one;
# one explicit header is clearer than two contradicting-looking ones.
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# index.html is the only mutable entry point — never cache it, or a deploy
# leaves clients pinned to a stale asset graph.
location = /index.html {
add_header Cache-Control "no-cache" always;
}
# SPA fallback. react-router-dom uses BrowserRouter, so a hard refresh or a
# shared link on a client-only route (/calendar, /settings, ...) arrives
# here as a real HTTP request for a path with no file behind it. Without
# this line that is a 404 and client-side routing breaks on reload.
location / {
try_files $uri $uri/ /index.html;
}
}