Files
CatubbaandGitHub 7aefc66324 fix(docker): build the SPA on node 24 to match CI (#11)
* fix(docker): build the SPA on node 24 to match CI

The frontend CI job runs on node 24 (npm 11) while the image built the SPA
on node:22-slim (npm 10). The two npm majors disagree on whether a violated
*optional* peer dependency is fatal: npm 11 warns, npm 10 fails with ERESOLVE.

So `npm ci` could pass every CI job and still fail `docker build` on the very
same lockfile — which is exactly what happened on the typescript 7 PR, where
i18next declares `peerOptional typescript@"^5 || ^6"`. CI was green; the image
build was not.

Align the build stage to node:24-slim so CI validates the same toolchain the
shipped image uses. Vite 8 requires node >=22.12, so 24 is in range.

Note this makes the image build as lenient as CI: a violated optional peer no
longer stops the build. That is the correct trade — the two environments agreeing
matters more than one of them tripping on type-only metadata — but it does remove
the tripwire that surfaced the typescript 7 conflict.

* ci: fail on node major drift between Dockerfile and ci.yml

The Dockerfile now carries a "keep this in sync with ci.yml" comment, and
comments drift. Assert it instead: parse the node major from the SPA build
stage and from node-version, and fail the build when they disagree.

Verified the guard actually fails, not just passes: drift (22 vs 24) errors,
a ci.yml declaring two different majors errors, and an unparseable Dockerfile
errors with an explanation rather than dying silently under `set -e`.
2026-07-09 11:07:26 +02:00

65 lines
3.0 KiB
Docker

# syntax=docker/dockerfile:1
#
# Joulenap image: build the SPA, then run FastAPI serving both the API and the built SPA.
# Build from the repo root: docker build -t catubba/joulenap:latest .
# --- Stage 1: build the SPA (Vite -> dist) -----------------------------------
# Debian (not alpine/musl) to avoid esbuild native-binary edge cases; this stage is
# discarded, so it doesn't affect the final image size.
#
# Keep this major in sync with `node-version` in .github/workflows/ci.yml. The Node
# major picks the bundled npm (22 -> npm 10, 24 -> npm 11), and the two npm majors
# disagree on whether a violated *optional* peer dependency is fatal. With CI on 24
# and this stage on 22, `npm ci` could pass every CI job and still fail the image
# build on the very same lockfile.
FROM node:24-slim AS frontend
WORKDIR /build
# Install deps first (cached until the lockfile changes), then build.
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build # tsc --noEmit && vite build -> /build/dist
# --- Stage 2: runtime (Python + FastAPI) -------------------------------------
FROM python:3.12-slim AS runtime
# Pin the runtime paths to /app so they don't depend on where the package is installed
# (app/paths.py otherwise derives a fallback from __file__, which lands in site-packages).
# config.yaml lives *inside* the data dir so a single directory mount (-v ...:/app/data)
# persists config + history + ssh key together — no separate single-file config mount, and
# the container self-seeds config.yaml from config.example.yaml on first run.
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
JOULENAP_CONFIG=/app/data/config.yaml \
JOULENAP_DATA_DIR=/app/data \
JOULENAP_FRONTEND_DIR=/app/frontend/dist
WORKDIR /app
# Install the backend package and its dependencies from pyproject. hatchling reads the
# README referenced as "../README.md", so it must be present in the build context.
# The cryptography/bcrypt/psutil wheels are manylinux prebuilds, so no compiler is needed.
COPY backend/ /src/backend/
COPY README.md /src/README.md
RUN pip install /src/backend && rm -rf /src
# Runtime files at the paths app/paths.py expects: /app/data is mounted by the user and
# holds config.yaml + history + ssh key; config.example.yaml is the first-run seed that
# ConfigStore copies to /app/data/config.yaml when it's missing.
COPY config.example.yaml /app/config.example.yaml
COPY --from=frontend /build/dist /app/frontend/dist
RUN mkdir -p /app/data
# NOTE: runs as root by design — data/ is bind-mounted from the host and the app writes to
# it (wizard saves, secret_key seeding, generated SSH key, config.yaml itself), so a fixed
# non-root UID would hit host-ownership mismatches. The UI is LAN/VPN-only (see README).
# Documentation only; the real bind port is app.port in config.yaml (default 8080).
EXPOSE 8080
# Probes /api/health on the configured port (reads app.port itself).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-m", "app.healthcheck"]
CMD ["joulenap"]