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:
@@ -0,0 +1,48 @@
|
||||
# Build context = backend/. Keep it to source + manifests.
|
||||
# podman reads .containerignore; docker reads .dockerignore (a symlink of this
|
||||
# file lives next to it).
|
||||
|
||||
# Virtualenvs / caches
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Tests, coverage, dev artifacts
|
||||
tests/
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
|
||||
# Local databases — never bake user data into an image
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
|
||||
# Secrets / local config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Scraper page dumps and verification screenshots: REAL PERSONAL DATA from
|
||||
# Försäkringskassan. These must never enter an image layer.
|
||||
captures/
|
||||
debug/
|
||||
|
||||
# Repo-level noise that is not part of this context anyway
|
||||
.git/
|
||||
.gitignore
|
||||
graphify-out/
|
||||
scripts/
|
||||
*.md
|
||||
!README.md
|
||||
Dockerfile
|
||||
.containerignore
|
||||
.dockerignore
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
.containerignore
|
||||
@@ -0,0 +1,203 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Föräldraledigplannerare — backend (FastAPI + uvicorn, Python 3.14, uv).
|
||||
#
|
||||
# Build context: backend/
|
||||
#
|
||||
# podman build -t foraldraledig-backend backend/ # slim default
|
||||
# podman build --target scraper -t foraldraledig-backend:scraper backend/ # + Chromium
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY TWO RUNTIMES
|
||||
#
|
||||
# `playwright` sits in [project].dependencies (not an extra), so the Python
|
||||
# package is always installed — but the *browser* it drives is a separate
|
||||
# ~170 MB download plus its system libraries, and Playwright publishes no
|
||||
# musl/Alpine build at all. Shipping Chromium in the default image would
|
||||
# several-fold inflate it for a feature most deployments never turn on.
|
||||
#
|
||||
# So:
|
||||
# * target `runtime` (default, and deliberately the LAST stage so a plain
|
||||
# `podman build` lands here) — Alpine, NO Chromium. Runs with
|
||||
# APP_SCRAPER_ENABLED=false baked in; the FK-scraper endpoints are the
|
||||
# only thing unavailable.
|
||||
# * target `scraper` (opt-in) — Debian slim + Chromium + its libs, built
|
||||
# from its own Debian dependency stage because musl wheels cannot be
|
||||
# reused on glibc.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Stage 1 — dependency build (glibc / Debian), only for the scraper variant
|
||||
# ===========================================================================
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS deps-glibc
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never \
|
||||
# Build the venv at its FINAL path. A uv/virtualenv venv is not
|
||||
# relocatable: console-script shebangs are absolute, so a venv built at
|
||||
# /build/.venv and copied to /app/.venv yields "uvicorn: not found"
|
||||
# (exit 127 — a missing interpreter, not a missing script).
|
||||
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||
|
||||
WORKDIR /build
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --locked --no-dev --no-install-project
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Stage 2 — opt-in scraper runtime: Debian slim + Chromium
|
||||
# ===========================================================================
|
||||
FROM python:3.14-slim-trixie AS scraper
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
APP_SQLITE_PATH=/data/foraldraledig.db \
|
||||
APP_SCRAPER_ENABLED=true \
|
||||
APP_SCRAPER_HEADLESS=true \
|
||||
# Browsers outside $HOME so they survive a read-only /app and any UID.
|
||||
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# --chown here, not a `chown -R /app` below: see the note in the default
|
||||
# runtime stage — a recursive chown duplicates the whole venv into a new layer.
|
||||
COPY --from=deps-glibc --chown=10001:10001 /app/.venv /app/.venv
|
||||
COPY --chown=10001:10001 alembic.ini ./
|
||||
COPY --chown=10001:10001 migrations ./migrations
|
||||
COPY --chown=10001:10001 app ./app
|
||||
|
||||
# `playwright install --with-deps` pulls Chromium *and* the system libraries
|
||||
# it needs. --only-shell installs the headless shell only (no headed browser,
|
||||
# no ffmpeg), which is all the FK scraper uses. This single line is why this
|
||||
# variant is big, and the whole reason it is opt-in.
|
||||
RUN python -m compileall -q /app/app /app/migrations \
|
||||
&& playwright install --with-deps --only-shell chromium \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.cache \
|
||||
&& groupadd -g 10001 app \
|
||||
&& useradd -u 10001 -g app -M -d /app app \
|
||||
&& mkdir -p /data \
|
||||
&& chown app:app /data \
|
||||
&& chmod -R a+rX /opt/playwright
|
||||
|
||||
VOLUME ["/data"]
|
||||
USER 10001:10001
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"]
|
||||
|
||||
# Chromium needs writable scratch space; with --read-only add
|
||||
# --tmpfs /tmp --tmpfs /dev/shm:size=256m
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Stage 3 — dependency build (musl / Alpine), for the default slim runtime
|
||||
# ===========================================================================
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-alpine AS deps-musl
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never \
|
||||
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||
|
||||
# No compiler needed: every dependency in uv.lock except playwright publishes
|
||||
# a cp314 musllinux aarch64/x86_64 wheel today (argon2-cffi, greenlet, uvloop,
|
||||
# httptools, pydantic-core all do). If a future dependency bump breaks that,
|
||||
# add `RUN apk add --no-cache build-base libffi-dev` here — it is builder-only
|
||||
# and never reaches the final image.
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Manifests first: this layer stays cached until the dependency set actually
|
||||
# changes, so source edits never re-resolve or re-compile the tree.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# --no-install-project: the app is plain source we COPY in below, so the venv
|
||||
# holds third-party dependencies only. --no-dev drops pytest/ruff/httpx.
|
||||
#
|
||||
# --no-install-package playwright: playwright ships NO musllinux wheel at all
|
||||
# (manylinux/macOS/win only), so on Alpine it is not merely undesirable, it is
|
||||
# uninstallable. Dropping it is safe *only* because the import is lazy —
|
||||
# app/scrapers/fk_scraper.py does `from playwright.async_api import
|
||||
# async_playwright` inside the function that drives the browser, not at module
|
||||
# scope, so `app.main` imports cleanly without it. The scraper is disabled via
|
||||
# APP_SCRAPER_ENABLED=false in the runtime stage; use the `scraper` target if
|
||||
# you need it. Keep that import lazy, or this stage breaks at boot.
|
||||
RUN uv sync --locked --no-dev --no-install-project --no-install-package playwright
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Stage 4 — DEFAULT runtime: Alpine, no build toolchain, no Chromium
|
||||
# ===========================================================================
|
||||
FROM python:3.14-alpine AS runtime
|
||||
|
||||
# Pinned by tag, not digest, on purpose: a tag pin keeps rebuilds picking up
|
||||
# Alpine/CPython security patches, which matters more here than bit-for-bit
|
||||
# reproducibility — and the *Python* dependency set is already byte-pinned by
|
||||
# uv.lock (`uv sync --locked`). Pin the digest in the deployment pipeline if
|
||||
# full supply-chain immutability outranks patching.
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
# SQLite lives on the mounted volume, never in an image layer.
|
||||
APP_SQLITE_PATH=/data/foraldraledig.db \
|
||||
# No Chromium in this image — see "WHY TWO RUNTIMES" above.
|
||||
APP_SCRAPER_ENABLED=false
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# --chown on the COPY itself: a later `chown -R /app` would rewrite every
|
||||
# file's metadata and so duplicate the entire 89 MB venv into a second layer
|
||||
# (it cost ~86 MB before this was fixed).
|
||||
COPY --from=deps-musl --chown=10001:10001 /app/.venv /app/.venv
|
||||
|
||||
# Source last — it changes most often, so it is the only layer that rebuilds.
|
||||
# alembic.ini and migrations/ must sit in WORKDIR: app/db/connection.py loads
|
||||
# `Config("alembic.ini")` by a path relative to the process CWD.
|
||||
COPY --chown=10001:10001 alembic.ini ./
|
||||
COPY --chown=10001:10001 migrations ./migrations
|
||||
COPY --chown=10001:10001 app ./app
|
||||
|
||||
# Pre-compile so a read-only root filesystem never needs to write .pyc files.
|
||||
RUN python -m compileall -q /app/app /app/migrations \
|
||||
&& addgroup -g 10001 -S app \
|
||||
&& adduser -u 10001 -S -G app -h /app app \
|
||||
&& mkdir -p /data \
|
||||
&& chown app:app /data
|
||||
|
||||
# SQLite database + its WAL/SHM sidecars. Mount a named volume or host path
|
||||
# here; without one the data dies with the container.
|
||||
#
|
||||
# Read-only root filesystem (verified working) — run with:
|
||||
# --read-only --tmpfs /tmp -v <volume>:/data
|
||||
# Nothing outside /data is written at runtime: bytecode is pre-compiled above
|
||||
# and PYTHONDONTWRITEBYTECODE is set.
|
||||
VOLUME ["/data"]
|
||||
|
||||
USER 10001:10001
|
||||
EXPOSE 8000
|
||||
|
||||
# /health is defined in app/main.py. urllib keeps this dependency-free — no
|
||||
# curl or wget needs to exist in the image.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"]
|
||||
|
||||
# Exec form => uvicorn is PID 1 and receives SIGTERM/SIGINT directly, so it
|
||||
# runs its own graceful shutdown (lifespan closes the DB connection) rather
|
||||
# than being SIGKILLed after the stop timeout. No init shim needed: a single
|
||||
# uvicorn process spawns no children and has nothing to reap.
|
||||
#
|
||||
# MIGRATIONS: the app lifespan already calls `alembic upgrade head` itself on
|
||||
# SQLite (app/main.py -> app/db/connection.py:run_migrations), so the schema is
|
||||
# applied on boot. That is forward-only `upgrade`, never `downgrade`, so no
|
||||
# destructive DDL runs implicitly. On Postgres (APP_DATABASE_TYPE=postgres) the
|
||||
# lifespan does NOT migrate — run it as a one-shot before rollout:
|
||||
# podman run --rm -e APP_DATABASE_TYPE=postgres ... <image> alembic upgrade head
|
||||
# To take the boot migration off the hot path on SQLite too, run that same
|
||||
# one-shot and treat the auto-migrate as a no-op safety net.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
|
||||
@@ -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
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
.containerignore
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user