From 65e35761eca16a73954b9f7f6c336e1c39ce048b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:53:20 +0100 Subject: [PATCH 1/6] build: probe container health with Node's fetch instead of wget The probe now needs nothing but the node binary the image already runs, which frees the base image from carrying wget. The image HEALTHCHECK and the compose healthcheck use the same one-liner: a non-2xx answer, a refused connection or the 4 s abort exit 1, inside Docker's 5 s timeout. The boot-storm guard reads the abort from the fetch and refuses a probe that names wget or curl. --- Dockerfile | 7 ++++-- docker-compose.yml | 8 ++++--- docs/self-hosting/scaling.md | 2 +- src/__tests__/healthcheck-boot-storm.test.ts | 25 ++++++++++++++------ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 37143e516..9b6a9a6d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -369,9 +369,12 @@ ENV HOSTNAME="0.0.0.0" # migration waiting out a held lock retries for up to about eight minutes. # Five retries: a pool saturated for a minute during a boot storm makes the # probe's 2 s database check miss once or twice without marking the container -# unhealthy (docker-compose.yml explains the numbers). +# unhealthy (docker-compose.yml explains the numbers). The probe is Node's own +# fetch: the Debian base carries neither wget nor curl, and installing one for +# this line would add a TLS stack to the image's scan surface. A non-2xx +# answer, a refused connection and the 4 s abort all exit 1. HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \ - CMD wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1 + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] ENTRYPOINT ["./docker-entrypoint.sh"] CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index b7e7faef3..d71dd0066 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -557,8 +557,10 @@ services: healthcheck: test: [ - "CMD-SHELL", - "wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1", + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))", ] interval: 30s timeout: 5s @@ -567,7 +569,7 @@ services: # past that, so a boot storm that saturates the pool for a minute after # the start period (backfills, a restore, another tenant's import) costs # a probe or two, not the container's health and the proxy's routing. - # wget's own 4 s timeout ends the request inside Docker's 5 s, so a + # The probe's own 4 s abort ends the request inside Docker's 5 s, so a # stalled connection reads as one clean miss rather than a killed probe. # A database that stays away for 2.5 minutes is a real outage and shows. retries: 5 diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 6ecac0731..16add088b 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -131,7 +131,7 @@ and restart the worker together with the web containers. ## Healthchecks -- `app` (web) healthcheck: `wget /api/health` every 30s. +- `app` (web) healthcheck: a `node -e` fetch of `/api/health` every 30s. - `app-worker` does not expose a port; its liveness is tracked in-memory inside the process (`src/lib/jobs/worker-status.ts`) and read back by `/api/admin/status`. In a web/worker split this means diff --git a/src/__tests__/healthcheck-boot-storm.test.ts b/src/__tests__/healthcheck-boot-storm.test.ts index e91b7401b..6a780da5b 100644 --- a/src/__tests__/healthcheck-boot-storm.test.ts +++ b/src/__tests__/healthcheck-boot-storm.test.ts @@ -79,10 +79,21 @@ function imageCheck(): Check { }; } -function wgetTimeout(command: string): number { - const m = /wget[^|]*--timeout=(\d+)/.exec(command); - if (!m) throw new Error(`no wget timeout in: ${command}`); - return Number(m[1]); +/** + * The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The + * image carries no wget or curl, so a probe that names one would fail every + * time and mark a healthy container unhealthy. + */ +function probeTimeout(command: string): number { + expect(command, "the probe needs a binary the image has").not.toMatch( + /\b(wget|curl)\b/, + ); + const m = + /fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health',\{signal:AbortSignal\.timeout\((\d+)\)\}\)/.exec( + command, + ); + if (!m) throw new Error(`no aborting /api/health fetch in: ${command}`); + return Number(m[1]) / 1000; } describe.each([ @@ -96,9 +107,9 @@ describe.each([ it("ends the request inside Docker's timeout, after the database probe's", () => { const check = read(); - const wget = wgetTimeout(check.command); - expect(wget).toBeGreaterThan(probeTimeoutSeconds); - expect(wget).toBeLessThan(check.timeout); + const abort = probeTimeout(check.command); + expect(abort).toBeGreaterThan(probeTimeoutSeconds); + expect(abort).toBeLessThan(check.timeout); }); }); From de7858100f03eb48e48fcfec38303bafbb4ddb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:53:20 +0100 Subject: [PATCH 2/6] build: run the image on Debian trixie instead of Alpine Every stage now starts from node:22-trixie-slim, pinned by its multi-arch index digest, so the native modules load their glibc builds. The arm64 musl Skia build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with SIGILL on an ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the glibc build runs there (#1311). The runner keeps what it had: tzdata, TZ, the build-time OS upgrade, the nextjs user (uid 1001, now in group nodejs gid 1001), the backup and GeoLite2 directories and every build-time assertion. pg_dump and pg_restore of major 18 come from the PGDG apt repository, whose signing key is fetched by node and checked against a pinned SHA-256 before apt trusts it; PATH names the major 18 directory so the binary does not depend on Debian's version-picking wrapper. The canvas hoist takes the -gnu binary package by the pinned version and still fails the build on a mismatch. bookworm-slim was the other candidate; on the day of the switch it carried a CRITICAL zlib finding that trixie-slim does not. --- .github/workflows/security.yml | 2 +- Dockerfile | 94 ++++++++++++++----- README.md | 2 +- scripts/__tests__/release-workflows.test.ts | 2 +- .../canvas-binary-hoist-guard.test.ts | 18 +++- 5 files changed, 90 insertions(+), 28 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 406816ccc..600271673 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -247,7 +247,7 @@ jobs: # Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built # image fails the job. For a finding without an upstream fix - # (e.g. a base-image CVE Alpine has not patched yet), add the CVE + # (e.g. a base-image CVE Debian has not patched yet), add the CVE # id to a `.trivyignore` file at the repository root — Trivy reads # it automatically — one CVE per line, each with a comment naming # the tracking issue and the re-check condition. Never suppress a diff --git a/Dockerfile b/Dockerfile index 9b6a9a6d9..21dc3d305 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,15 @@ +# ── Base image ───────────────────────────────────────────── +# Debian (glibc), not Alpine (musl), in every stage. The native modules the +# runtime loads (the @napi-rs/canvas Skia renderer, @node-rs/argon2, the +# Prisma schema engine) then run their glibc builds. The arm64 musl Skia +# build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with SIGILL on an +# ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the glibc arm64 +# build does not use those instructions (#1311). trixie over bookworm: on +# the day of the switch the bookworm-slim base carried a CRITICAL finding +# (zlib, marked will-not-fix) that trixie-slim does not. + # ── Stage 1: Dependencies ────────────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps +FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app @@ -11,7 +21,7 @@ COPY scripts/prepare.mjs scripts/prepare.mjs RUN pnpm install --frozen-lockfile --prod=false # ── Stage 2: Build ───────────────────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder +FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS builder RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app @@ -67,23 +77,62 @@ RUN node scripts/verify-message-catalogs.mjs --dir messages \ --config src/lib/i18n/config.ts --write /app/.message-locales # ── Stage 3: Production runner ───────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner +FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner # `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware # timestamp formatting) resolve to the actual offset instead of silently -# falling back to UTC on Alpine images that ship without it. +# falling back to UTC; the slim base ships without it. # -# `apk upgrade` pulls in OS-package security fixes the pinned base digest +# `apt-get upgrade` pulls in OS-package security fixes the pinned base digest # has not absorbed yet (the digest pin stays for build reproducibility; -# security patches from the Alpine repo override it deliberately — +# security patches from the Debian archive override it deliberately — # CVE-2026-14456 in openssl was the first instance the container scan # caught between base-image rebuilds). # -# `postgresql18-client` carries `pg_dump` and `pg_restore` for the nightly +# `postgresql-client-18` carries `pg_dump` and `pg_restore` for the nightly # database dump, the pre-migration dump in the entrypoint and the # `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the # bundled database; a client dumps servers of its own major and older, so it -# also serves an install still on its own Postgres 16. -RUN apk add --no-cache tzdata postgresql18-client && apk upgrade --no-cache +# also serves an install still on its own Postgres 16. Debian trixie itself +# ships major 17, so the client comes from the PostgreSQL project's own apt +# repository (PGDG). Its signing key is fetched over HTTPS and must match the +# pinned SHA-256 before apt trusts it; the pin is the key with fingerprint +# B97B 0AFC AA1A 47F0 44F2 44A0 7FCC 7D46 ACCC 4CF8. If PGDG ever republishes +# the key file, check that fingerprint again before moving the pin. `node` +# fetches it, so no curl or wget lands in the image for this one download. +# `ca-certificates` is what apt needs for the HTTPS repository. +RUN set -eux; \ + export PGDG_ASC_SHA256=0144068502a1eddd2a0280ede10ef607d1ec592ce819940991203941564e8e76; \ + export DEBIAN_FRONTEND=noninteractive; \ + apt-get update; \ + apt-get upgrade -y --no-install-recommends; \ + apt-get install -y --no-install-recommends ca-certificates tzdata; \ + node -e " \ + const { createHash } = require('node:crypto'); \ + const { writeFileSync } = require('node:fs'); \ + fetch('https://www.postgresql.org/media/keys/ACCC4CF8.asc', { signal: AbortSignal.timeout(30000) }) \ + .then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) \ + .then((b) => { \ + const key = Buffer.from(b); \ + const sum = createHash('sha256').update(key).digest('hex'); \ + if (sum !== process.env.PGDG_ASC_SHA256) throw new Error('PGDG key sha256 ' + sum); \ + writeFileSync('/usr/share/keyrings/pgdg.asc', key); \ + }) \ + .catch((e) => { console.error(e); process.exit(1); });"; \ + printf '%s\n' \ + 'Types: deb' \ + 'URIs: https://apt.postgresql.org/pub/repos/apt' \ + 'Suites: trixie-pgdg' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/pgdg.asc' \ + > /etc/apt/sources.list.d/pgdg.sources; \ + apt-get update; \ + apt-get install -y --no-install-recommends postgresql-client-18; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* +# Debian installs each major's client under its own directory and puts a +# version-picking Perl wrapper on PATH. Name the directory, so `pg_dump` +# is the major 18 binary by construction, not by what the wrapper picks. +ENV PATH=/usr/lib/postgresql/18/bin:$PATH WORKDIR /app ENV NODE_ENV=production @@ -114,8 +163,9 @@ ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ARG NEXT_PUBLIC_APP_BUILD_SHA ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nextjs +RUN groupadd --system --gid 1001 nodejs && \ + useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs \ + --shell /usr/sbin/nologin nextjs # Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a # fresh named volume mounted here starts with that owner: Docker fills an @@ -163,8 +213,8 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')" # HOISTED to the top level there (verified: without it pdfjs logs "Cannot load # @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError); # (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH -# the canvas package and the musl binary package to /app/node_modules/@napi-rs -# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas +# the canvas package and the glibc (`-gnu`) binary package to +# /app/node_modules/@napi-rs via symlink into the .pnpm store, and drop the prebuilt .node into the canvas # dir: the loader tries that local file (`require('./skia..node')`) # before the platform package, so it decides which binary runs. # @@ -181,24 +231,24 @@ RUN set -e; \ CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \ rm -f /tmp/.canvas-version; \ CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \ - MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \ - if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \ - echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \ + GNU_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-gnu@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-gnu" 2>/dev/null | head -1)"; \ + if [ ! -d "$CANVAS_DIR" ] || [ -z "$GNU_DIR" ]; then \ + echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its glibc binary did not reach the traced image."; \ echo "Present instead:"; \ find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \ exit 1; \ fi; \ - NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \ - [ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \ + NODE_BIN="$(find "$GNU_DIR" -maxdepth 1 -name 'skia.linux-*-gnu.node' | head -1)"; \ + [ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $GNU_DIR"; exit 1; }; \ mkdir -p /app/node_modules/@napi-rs; \ ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \ - ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \ + ln -sfn "$GNU_DIR" "/app/node_modules/@napi-rs/$(basename "$GNU_DIR")"; \ cp "$NODE_BIN" "$CANVAS_DIR/"; \ chown -R nextjs:nodejs /app/node_modules/@napi-rs; \ - BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \ + BIN_VERSION="$(node -p "require('$GNU_DIR/package.json').version")"; \ [ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \ cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \ - echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)" + echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $GNU_DIR)" # pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render # path breaks in the standalone image), so the server does a runtime bare @@ -340,7 +390,7 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | # user so the runtime worker can WRITE it: a self-hoster who sets # MAXMIND_LICENSE_KEY on the published image has the databases fetched into # this directory at runtime (src/lib/geo/geolite2-fetch.ts) with no rebuild or -# manual mount. `tar`/`gzip` for the extraction ship in the busybox base. A +# manual mount. `tar`/`gzip` for the extraction are part of the Debian base. A # read-only bind mount at this path (the bring-your-own-database route) still # works — the runtime fetch just fails soft and the mounted files stand. RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2 diff --git a/README.md b/README.md index 4c880ab87..c5f60968c 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The App Store listing carries a small price, which exists to cover Apple's yearl ## Tech stack -Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). +Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Debian (glibc) Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). ## Documentation diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index c402102b8..61911c6a9 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -227,7 +227,7 @@ describe("release container inputs", () => { expect(fromLines).toHaveLength(3); expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual( new Set([ - "FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2", + "FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f", ]), ); }); diff --git a/src/__tests__/canvas-binary-hoist-guard.test.ts b/src/__tests__/canvas-binary-hoist-guard.test.ts index 45bcc3d85..56735461b 100644 --- a/src/__tests__/canvas-binary-hoist-guard.test.ts +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -6,7 +6,7 @@ * * The pnpm store held two canvas versions: the one in package.json and an * older one that a second, older pdfjs-dist pulled in (gone since #1277). - * The runner stage picked the musl binary with `find | head -1`, got the + * The runner stage picked the platform binary with `find | head -1`, got the * old one, and copied it into the new loader's directory, where the loader * looks first. PDF rendering still worked, so nothing looked wrong; * `loadImage` called an `Image.decode` the old binary lacks, and every @@ -43,7 +43,7 @@ describe("canvas binary hoist", () => { ); }); - it("hoists the loader and the musl binary of that version only", () => { + it("hoists the loader and the glibc binary of that version only", () => { const runner = stage("runner"); expect(runner).toContain( "COPY --from=builder /app/.canvas-version /tmp/.canvas-version", @@ -51,12 +51,24 @@ describe("canvas binary hoist", () => { expect(runner).toContain( 'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/', ); - expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/"); + // The image is Debian: the glibc (`-gnu`) build is the one that runs, + // and the musl build, which needs ARMv8.2 on arm64 (#1311), is not. + expect(runner).toContain("canvas-linux-*-gnu@${CANVAS_VERSION}/"); + expect(runner).toContain("-name 'skia.linux-*-gnu.node'"); + expect(runner).not.toMatch(/-musl/); // A version-blind pick is the defect: any `canvas…@*` glob decides by // readdir order which of the store's copies wins. expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/); }); + it("builds every stage on the glibc Debian base", () => { + const froms = DOCKERFILE.match(/^FROM \S+/gm) ?? []; + expect(froms.length).toBeGreaterThanOrEqual(3); + for (const from of froms) { + expect(from).toMatch(/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64}$/); + } + }); + it("fails the build when the copied binary is another version", () => { const runner = stage("runner"); expect(runner).toMatch( From 3d0ae051920047bd352dc382609f20834a8b2ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:53:28 +0100 Subject: [PATCH 3/6] docs: say the published image renders documents on a Raspberry Pi 4 The image runs the glibc canvas build now, which needs no ARMv8.2 features, so the Pi 4 gets thumbnails and scanned-PDF rendering back without a code change: the gate only ever closed for musl on arm64. The gate's comment, the env example and the getting-started prerequisites say so, and keep NATIVE_CANVAS as the override. The x64 AVX2 gate stays: the glibc x64 build rendered a PDF page under an emulated core without AVX2, but it still carries AVX2 code and that one run does not cover every path. --- .env.production.example | 11 ++++---- docs/self-hosting/getting-started.md | 9 ++++++- src/lib/documents/native-canvas-support.ts | 31 +++++++++++++--------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/.env.production.example b/.env.production.example index b82aae357..207bd3d33 100644 --- a/.env.production.example +++ b/.env.production.example @@ -706,11 +706,12 @@ API_TOKEN_HMAC_KEY="" # ENVIRONMENT_AIR_QUALITY_DISABLED="1" # ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ── -# The bundled renderer needs a recent CPU: AVX2 on x86-64, and ARMv8.2 features -# (LSE atomics, RCpc) on arm64. On older CPUs (Celeron/Atom-class NAS chips, the -# Raspberry Pi 4) it is disabled automatically and those features degrade -# cleanly (no thumbnails; scanned PDFs read as text only). Override only if -# auto-detection misfires: +# On x86-64 the renderer is enabled only on CPUs with AVX2. On older chips +# (Celeron/Atom-class NAS CPUs) it is disabled automatically and those features +# degrade cleanly (no thumbnails; scanned PDFs read as text only). On arm64 the +# published image renders on every 64-bit core, the Raspberry Pi 4 included. +# Only a musl (Alpine) build of your own needs ARMv8.2 (LSE atomics, RCpc) and +# is gated the same way. Override only if auto-detection misfires: # off — force-disable the renderer # on — force-enable (crashes PDF processing if the CPU truly lacks them) # NATIVE_CANVAS="off" diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 2575600c4..156a7333e 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -9,7 +9,14 @@ already installed, longer if a TLS-fronted public hostname is in scope - **Docker 24+ with Compose v2.** The bundled `docker-compose.yml` pulls a multi-arch image from GitHub Container Registry (linux/amd64 - and linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged. + and linux/arm64), so a Raspberry Pi 4 or 5 or an x86 VPS all work + unchanged. The image is Debian-based (glibc). Document thumbnails and + scanned-PDF rendering use a native renderer that runs on every arm64 + board, the Pi 4 included, and on x86-64 CPUs with AVX2; on an older x86 + CPU (Celeron/Atom-class NAS chips) it switches itself off and those two + features fall back to text only. `NATIVE_CANVAS=off` or `on` in `.env` + overrides that detection (`on` crashes PDF processing on a CPU that truly + lacks the instructions). - **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data volume grows with measurement history; the Apple Health import worker can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app diff --git a/src/lib/documents/native-canvas-support.ts b/src/lib/documents/native-canvas-support.ts index 88f496254..1006d68d9 100644 --- a/src/lib/documents/native-canvas-support.ts +++ b/src/lib/documents/native-canvas-support.ts @@ -1,13 +1,19 @@ /** * v1.28.27 — CPU-feature gate for the native canvas renderer. * - * The bundled `@napi-rs/canvas` (Skia) x64 builds use AVX2 instructions. On an + * The `@napi-rs/canvas` (Skia) x64 builds carry AVX2 instructions. On an * x86-64 host WITHOUT AVX2 — Celeron/Atom-class NAS CPUs (Goldmont Plus and - * older) are the common self-hosting case — the first canvas call dies with - * SIGILL (`trap invalid opcode … in skia.linux-x64-musl.node`), which kills the - * whole Node process and puts the container into an infinite restart loop - * (live incident: a Synology DS1520+ self-host, exit code 132 ~18 s after - * every boot once the thumbnail backfill touched Skia). + * older) are the common self-hosting case — the musl build died on the first + * canvas call with SIGILL (`trap invalid opcode … in skia.linux-x64-musl.node`), + * which kills the whole Node process and puts the container into an infinite + * restart loop (live incident: a Synology DS1520+ self-host, exit code 132 + * ~18 s after every boot once the thumbnail backfill touched Skia). + * + * The published image is Debian (glibc) and runs the glibc build. Under an + * emulated AVX2-less Atom core that build drew a canvas and rendered + * a PDF page to JPEG where the musl build died, but it still carries AVX2 code + * and not every path it can take was exercised, so the x64 gate stays closed + * without the flag. `NATIVE_CANVAS=on` is the way to try it on such a host. * * A SIGILL cannot be caught in-process, so the only safe posture is to never * load the module on an unsupported CPU. Every canvas consumer (document @@ -15,12 +21,13 @@ * the gate closed those features degrade cleanly (no thumbnails, PDFs read as * text/native only) while the rest of the app runs normally. * - * The arm64 musl build (the one the published arm64 image runs) is compiled - * for ARMv8.2 and later: it uses LSE atomics (`ldaddal`, `casal`) and RCpc - * loads (`ldapr`) throughout, with no runtime fallback, and dies with SIGILL - * as soon as the module loads on an ARMv8.0 core such as the Raspberry Pi 4's - * Cortex-A72. The glibc arm64 build uses neither, so a glibc host is left - * alone. + * The arm64 musl build is compiled for ARMv8.2 and later: it uses LSE atomics + * (`ldaddal`, `casal`) and RCpc loads (`ldapr`) throughout, with no runtime + * fallback, and dies with SIGILL as soon as the module loads on an ARMv8.0 + * core such as the Raspberry Pi 4's Cortex-A72 (#1311). The glibc arm64 build + * uses neither and runs there, which is why the published image moved off + * Alpine. The musl branch stays for a host that runs the app on musl itself, + * from source on Alpine or from an image built on it. * * Detection reads `/proc/cpuinfo` once on Linux: x64 requires the `avx2` * flag, arm64 under musl requires the `atomics` and `lrcpc` features. Every From d8743e911127a9bc594086012043315b7f45bc71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:53:29 +0100 Subject: [PATCH 4/6] chore: drop the trivyignore entries for npm's bundled packages Both covered copies inside the npm CLI of the Node base image. The runner removes npm, so neither CVE matches anything in the image; a scan of the built image with an empty ignore file reports neither. --- .trivyignore | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.trivyignore b/.trivyignore index 34a98f1b7..d8ad25576 100644 --- a/.trivyignore +++ b/.trivyignore @@ -3,25 +3,6 @@ # can apply; never suppress one with a fixed version available — bump instead. # Each entry names what it covers and the condition to re-check / remove it. -# CVE-2026-33671 (picomatch ReDoS, HIGH) — flagged only in the npm CLI's -# OWN bundled copy that ships inside the Node base image -# (usr/local/lib/node_modules/npm/node_modules/picomatch, v4.0.3), used at -# image-build time by npm, not by our runtime. Our application dependency -# tree pins picomatch >= 4.0.4 via a pnpm override, so the running app is -# not affected and the path is not reachable at runtime. Re-check when the -# Node base image ships an npm build whose bundled picomatch is >= 4.0.4, -# then drop this entry. -CVE-2026-33671 - -# CVE-2026-48815 (sigstore certificate-OID verification, HIGH) — flagged only -# in the npm CLI's OWN bundled copy inside the Node base image -# (usr/local/lib/node_modules/npm/node_modules/sigstore, v3.1.0). npm uses -# sigstore for publish provenance at image-build time only; it is not in our -# application dependency tree at all (`pnpm why sigstore` is empty) and is -# never reachable at runtime. Re-check when the Node base image ships an npm -# build whose bundled sigstore is >= 4.1.1, then drop this entry. -CVE-2026-48815 - # CVE-2026-85393 (node-forge RSA PKCS#1 v1.5 signature forgery, HIGH; GitHub # advisory GHSA-86w9-cpqp-85rv) — node-forge <= 1.4.0 arrives only through # @parse/node-apn, which uses it in its certificate-based credential path From 377e0d0d3affb51bc595de2bc277e72155e7aea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 21:18:44 +0100 Subject: [PATCH 5/6] build: copy pg_dump and pg_restore from the PostgreSQL image postgresql-client-18 from the PGDG repository brought Perl and Debian's client wrapper scripts into the runner for two binaries the app calls by name. A pgclient stage on postgres:18.6-trixie, pinned by digest, now hands over pg_dump, pg_restore and the libpq they link; the Debian libraries libpq needs (Kerberos, LDAP, SASL) still come from apt so the upgrade and the image scan see them. The runner registers libpq with the loader and fails the build when either binary has an unresolved library; the major 18 version checks stay. The client minor follows the bundled database's: a guard holds the two pins together, and the Dockerfile's Dependabot watch now ignores a PostgreSQL major the same way the compose watch does. --- .github/dependabot.yml | 7 ++ Dockerfile | 77 +++++++++---------- .../canvas-binary-hoist-guard.test.ts | 14 +++- src/__tests__/database-pin-is-watched.test.ts | 26 +++++++ 4 files changed, 82 insertions(+), 42 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 985a0c309..037570419 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -64,6 +64,13 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 3 + ignore: + # The Dockerfile's `pgclient` stage carries pg_dump and pg_restore of + # the bundled database's major. A major bump there is part of the same + # migration decision as the database itself, and the runner's paths and + # version checks name major 18, so it arrives by hand, not as a PR. + - dependency-name: "postgres" + update-types: ["version-update:semver-major"] # The `docker` ecosystem above reads the Dockerfile only. The bundled # database lives in `docker-compose.yml`, which GitHub treats as its own # ecosystem, so without this block a new PostgreSQL minor release never diff --git a/Dockerfile b/Dockerfile index 21dc3d305..7adac7ffb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,7 +76,23 @@ RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" RUN node scripts/verify-message-catalogs.mjs --dir messages \ --config src/lib/i18n/config.ts --write /app/.message-locales -# ── Stage 3: Production runner ───────────────────────────── +# ── Stage 3: PostgreSQL client tools ─────────────────────── +# `pg_dump` and `pg_restore` of major 18, and the libpq they link, taken from +# the official PostgreSQL image built on the same Debian release. Installing +# `postgresql-client-18` instead pulls Perl and Debian's client wrapper +# scripts into the runner for two binaries the app calls by name. Pinned to +# the same minor as the bundled database in docker-compose.yml; move the two +# together. The libpq file is found through the loader rather than by an +# architecture path, so the step is the same on amd64 and arm64. +FROM postgres:18.6-trixie@sha256:3a4264520e121176619f54ff867d12774bc81e95e7b84db58d63bccdb6144b81 AS pgclient +RUN set -eu; \ + mkdir -p /out/bin /out/lib; \ + cp /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore /out/bin/; \ + LIBPQ="$(ldd /usr/lib/postgresql/18/bin/pg_dump | awk '$1 == "libpq.so.5" { print $3 }')"; \ + [ -n "$LIBPQ" ] || { echo "ERROR: pg_dump links no libpq.so.5"; exit 1; }; \ + cp -L "$LIBPQ" /out/lib/libpq.so.5 + +# ── Stage 4: Production runner ───────────────────────────── FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner # `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware # timestamp formatting) resolve to the actual offset instead of silently @@ -88,50 +104,33 @@ FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab # CVE-2026-14456 in openssl was the first instance the container scan # caught between base-image rebuilds). # -# `postgresql-client-18` carries `pg_dump` and `pg_restore` for the nightly -# database dump, the pre-migration dump in the entrypoint and the -# `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the -# bundled database; a client dumps servers of its own major and older, so it -# also serves an install still on its own Postgres 16. Debian trixie itself -# ships major 17, so the client comes from the PostgreSQL project's own apt -# repository (PGDG). Its signing key is fetched over HTTPS and must match the -# pinned SHA-256 before apt trusts it; the pin is the key with fingerprint -# B97B 0AFC AA1A 47F0 44F2 44A0 7FCC 7D46 ACCC 4CF8. If PGDG ever republishes -# the key file, check that fingerprint again before moving the pin. `node` -# fetches it, so no curl or wget lands in the image for this one download. -# `ca-certificates` is what apt needs for the HTTPS repository. +# `pg_dump` and `pg_restore` serve the nightly database dump, the +# pre-migration dump in the entrypoint and the `db-restore` service +# (docs/ops/disaster-recovery.md). Major 18 matches the bundled database; a +# client dumps servers of its own major and older, so it also serves an +# install still on its own Postgres 16. Debian trixie ships major 17, so the +# two binaries and their libpq come from the `pgclient` stage above; the +# Debian libraries libpq links (Kerberos, LDAP, SASL) come from apt below, so +# the build-time upgrade and the image scan see them. The copied binaries are +# outside dpkg: the scan does not see them and `apt-get upgrade` does not +# patch them, so a PostgreSQL client fix arrives by moving the `pgclient` +# pin. +# +# `ca-certificates` is the system trust store the OpenSSL-linked tools use +# (the Prisma schema engine, libpq) for a TLS connection to the database. RUN set -eux; \ - export PGDG_ASC_SHA256=0144068502a1eddd2a0280ede10ef607d1ec592ce819940991203941564e8e76; \ export DEBIAN_FRONTEND=noninteractive; \ apt-get update; \ apt-get upgrade -y --no-install-recommends; \ - apt-get install -y --no-install-recommends ca-certificates tzdata; \ - node -e " \ - const { createHash } = require('node:crypto'); \ - const { writeFileSync } = require('node:fs'); \ - fetch('https://www.postgresql.org/media/keys/ACCC4CF8.asc', { signal: AbortSignal.timeout(30000) }) \ - .then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) \ - .then((b) => { \ - const key = Buffer.from(b); \ - const sum = createHash('sha256').update(key).digest('hex'); \ - if (sum !== process.env.PGDG_ASC_SHA256) throw new Error('PGDG key sha256 ' + sum); \ - writeFileSync('/usr/share/keyrings/pgdg.asc', key); \ - }) \ - .catch((e) => { console.error(e); process.exit(1); });"; \ - printf '%s\n' \ - 'Types: deb' \ - 'URIs: https://apt.postgresql.org/pub/repos/apt' \ - 'Suites: trixie-pgdg' \ - 'Components: main' \ - 'Signed-By: /usr/share/keyrings/pgdg.asc' \ - > /etc/apt/sources.list.d/pgdg.sources; \ - apt-get update; \ - apt-get install -y --no-install-recommends postgresql-client-18; \ + apt-get install -y --no-install-recommends \ + ca-certificates tzdata libgssapi-krb5-2 libldap2 libsasl2-2; \ apt-get clean; \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* -# Debian installs each major's client under its own directory and puts a -# version-picking Perl wrapper on PATH. Name the directory, so `pg_dump` -# is the major 18 binary by construction, not by what the wrapper picks. +COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/ +COPY --from=pgclient /out/lib/libpq.so.5 /usr/lib/postgresql/18/lib/libpq.so.5 +RUN echo /usr/lib/postgresql/18/lib > /etc/ld.so.conf.d/postgresql-18.conf && \ + ldconfig && \ + ! ldd /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore | grep -q "not found" ENV PATH=/usr/lib/postgresql/18/bin:$PATH WORKDIR /app diff --git a/src/__tests__/canvas-binary-hoist-guard.test.ts b/src/__tests__/canvas-binary-hoist-guard.test.ts index 56735461b..092be8a34 100644 --- a/src/__tests__/canvas-binary-hoist-guard.test.ts +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -61,12 +61,20 @@ describe("canvas binary hoist", () => { expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/); }); - it("builds every stage on the glibc Debian base", () => { + it("builds every Node stage on the glibc Debian base", () => { const froms = DOCKERFILE.match(/^FROM \S+/gm) ?? []; - expect(froms.length).toBeGreaterThanOrEqual(3); - for (const from of froms) { + const node = froms.filter((from) => from.startsWith("FROM node:")); + expect(node).toHaveLength(3); + for (const from of node) { expect(from).toMatch(/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64}$/); } + // The only other stage supplies the PostgreSQL client tools, built on + // the same Debian release so its binaries find their libraries. + expect(froms.filter((from) => !from.startsWith("FROM node:"))).toEqual([ + expect.stringMatching( + /^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64}$/, + ), + ]); }); it("fails the build when the copied binary is another version", () => { diff --git a/src/__tests__/database-pin-is-watched.test.ts b/src/__tests__/database-pin-is-watched.test.ts index 84d2d4cea..a9350eafd 100644 --- a/src/__tests__/database-pin-is-watched.test.ts +++ b/src/__tests__/database-pin-is-watched.test.ts @@ -40,6 +40,32 @@ describe("the bundled database pin", () => { } }); + it("ships pg_dump of the same minor the bundled database runs", () => { + const db = /^\s*image:\s*postgres:(\d+\.\d+)-alpine$/m.exec( + read("docker-compose.yml"), + ); + const client = + /^FROM postgres:(\d+\.\d+)-trixie@sha256:[0-9a-f]{64} AS pgclient$/m.exec( + read("Dockerfile"), + ); + expect(db, "the compose file pins the database").not.toBeNull(); + expect(client, "the Dockerfile has a pinned pgclient stage").not.toBeNull(); + // The two pins are one decision; a Dependabot PR moves them separately. + expect(client![1]).toBe(db![1]); + }); + + it("keeps a major bump of the client tools out of the Dockerfile watch too", () => { + const dependabot = read(".github/dependabot.yml"); + const docker = + /package-ecosystem:\s*"docker"\n([\s\S]*?)(?=\n\s*- package-ecosystem:)/.exec( + dependabot, + ); + expect(docker, "a docker ecosystem entry").not.toBeNull(); + expect(docker![1]).toMatch( + /dependency-name:\s*"postgres"[\s\S]{0,200}version-update:semver-major/, + ); + }); + it("lets a major version through only as a decision, never as an update PR", () => { const dependabot = read(".github/dependabot.yml"); // The first run of the compose ecosystem proposed 16.15 -> 18.6. A From 5d36afb487811c1275acdf366f85adde77384f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 21:55:30 +0100 Subject: [PATCH 6/6] build: run the image on a Wolfi runner The dependency and build stages stay on node:22-trixie-slim, where pnpm picks the glibc native packages. The runner moves to wolfi-base, pinned by its index digest: glibc, about fifty packages, and every one of them known to the image scan, Node and the PostgreSQL client included. nodejs-22, tzdata and postgresql-18-client come from apk, so the pgclient stage and its pin guard go. The builder writes its Node module ABI and glibc version out and the runner refuses to build on another ABI or an older glibc. Wolfi's npm is major 12 and runs no install scripts unless package.json allows them, so the Prisma CLI install names the three packages that have one, and the build checks that the schema engine file is there before asking the CLI about it. npm is installed for that step and removed in the same layer; the check that nothing is left asks a fresh shell, whose command cache cannot remember the deleted binary. --- .github/dependabot.yml | 7 - .github/workflows/security.yml | 2 +- Dockerfile | 150 +++++++++--------- README.md | 2 +- docs/self-hosting/getting-started.md | 2 +- scripts/__tests__/release-workflows.test.ts | 8 +- .../canvas-binary-hoist-guard.test.ts | 28 ++-- src/__tests__/database-pin-is-watched.test.ts | 26 --- src/lib/documents/native-canvas-support.ts | 4 +- 9 files changed, 105 insertions(+), 124 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 037570419..985a0c309 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -64,13 +64,6 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 3 - ignore: - # The Dockerfile's `pgclient` stage carries pg_dump and pg_restore of - # the bundled database's major. A major bump there is part of the same - # migration decision as the database itself, and the runner's paths and - # version checks name major 18, so it arrives by hand, not as a PR. - - dependency-name: "postgres" - update-types: ["version-update:semver-major"] # The `docker` ecosystem above reads the Dockerfile only. The bundled # database lives in `docker-compose.yml`, which GitHub treats as its own # ecosystem, so without this block a new PostgreSQL minor release never diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 600271673..53f34f46c 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -247,7 +247,7 @@ jobs: # Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built # image fails the job. For a finding without an upstream fix - # (e.g. a base-image CVE Debian has not patched yet), add the CVE + # (e.g. a base-image CVE Wolfi has not patched yet), add the CVE # id to a `.trivyignore` file at the repository root — Trivy reads # it automatically — one CVE per line, each with a comment naming # the tracking issue and the re-check condition. Never suppress a diff --git a/Dockerfile b/Dockerfile index 7adac7ffb..419035ea9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,17 @@ -# ── Base image ───────────────────────────────────────────── -# Debian (glibc), not Alpine (musl), in every stage. The native modules the -# runtime loads (the @napi-rs/canvas Skia renderer, @node-rs/argon2, the -# Prisma schema engine) then run their glibc builds. The arm64 musl Skia -# build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with SIGILL on an -# ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the glibc arm64 -# build does not use those instructions (#1311). trixie over bookworm: on -# the day of the switch the bookworm-slim base carried a CRITICAL finding -# (zlib, marked will-not-fix) that trixie-slim does not. +# ── Base images ──────────────────────────────────────────── +# glibc, not musl, in every stage. The native modules the runtime loads (the +# @napi-rs/canvas Skia renderer, @node-rs/argon2, the Prisma schema engine) +# then run their glibc builds. The arm64 musl Skia build needs ARMv8.2 (LSE +# atomics, RCpc loads) and dies with SIGILL on an ARMv8.0 core such as the +# Raspberry Pi 4's Cortex-A72; the glibc arm64 build does not use those +# instructions (#1311). +# +# The dependency and build stages run on Debian (node:22-trixie-slim), where +# pnpm installs the `-gnu` native packages. The runner is Wolfi: glibc, and +# a package set small enough that the image scan reports what the app ships +# rather than a general-purpose distribution's base system. A glibc binary +# runs on the glibc it was linked against or a newer one; the runner checks +# both that and the Node module ABI against the builder at build time. # ── Stage 1: Dependencies ────────────────────────────────── FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps @@ -67,6 +72,10 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build # name; see the note there for why guessing was wrong. RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \ > /app/.pdfjs-version +# The Node module ABI and the glibc the native packages were installed for; +# the runner refuses to build on an older glibc or another module ABI. +RUN node -p "process.versions.modules + ' ' + process.report.getReport().header.glibcVersionRuntime + ' ' + process.version" \ + > /app/.native-abi # The same for @napi-rs/canvas, whose native binary must match its JS loader. RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \ > /app/.canvas-version @@ -76,62 +85,40 @@ RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" RUN node scripts/verify-message-catalogs.mjs --dir messages \ --config src/lib/i18n/config.ts --write /app/.message-locales -# ── Stage 3: PostgreSQL client tools ─────────────────────── -# `pg_dump` and `pg_restore` of major 18, and the libpq they link, taken from -# the official PostgreSQL image built on the same Debian release. Installing -# `postgresql-client-18` instead pulls Perl and Debian's client wrapper -# scripts into the runner for two binaries the app calls by name. Pinned to -# the same minor as the bundled database in docker-compose.yml; move the two -# together. The libpq file is found through the loader rather than by an -# architecture path, so the step is the same on amd64 and arm64. -FROM postgres:18.6-trixie@sha256:3a4264520e121176619f54ff867d12774bc81e95e7b84db58d63bccdb6144b81 AS pgclient -RUN set -eu; \ - mkdir -p /out/bin /out/lib; \ - cp /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore /out/bin/; \ - LIBPQ="$(ldd /usr/lib/postgresql/18/bin/pg_dump | awk '$1 == "libpq.so.5" { print $3 }')"; \ - [ -n "$LIBPQ" ] || { echo "ERROR: pg_dump links no libpq.so.5"; exit 1; }; \ - cp -L "$LIBPQ" /out/lib/libpq.so.5 - -# ── Stage 4: Production runner ───────────────────────────── -FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner -# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware -# timestamp formatting) resolve to the actual offset instead of silently -# falling back to UTC; the slim base ships without it. +# ── Stage 3: Production runner ───────────────────────────── +FROM cgr.dev/chainguard/wolfi-base:latest@sha256:629d9acaceda7746cd9293365eb0d8e8fabf74e91830b05470cfbee20b53f447 AS runner +# `nodejs-22` is the runtime; the builder's Node 22 and this one share the +# module ABI, which the check below holds. `tzdata` is required so +# Europe/Berlin schedules (pg-boss cron, locale-aware timestamp formatting) +# resolve to the actual offset instead of silently falling back to UTC. # -# `apt-get upgrade` pulls in OS-package security fixes the pinned base digest -# has not absorbed yet (the digest pin stays for build reproducibility; -# security patches from the Debian archive override it deliberately — -# CVE-2026-14456 in openssl was the first instance the container scan -# caught between base-image rebuilds). +# `apk upgrade` pulls in package security fixes the pinned base digest has +# not absorbed yet (the digest pin stays for build reproducibility; fixes +# from the Wolfi repository override it deliberately — CVE-2026-14456 in +# openssl was the first instance the container scan caught between +# base-image rebuilds). # -# `pg_dump` and `pg_restore` serve the nightly database dump, the -# pre-migration dump in the entrypoint and the `db-restore` service -# (docs/ops/disaster-recovery.md). Major 18 matches the bundled database; a -# client dumps servers of its own major and older, so it also serves an -# install still on its own Postgres 16. Debian trixie ships major 17, so the -# two binaries and their libpq come from the `pgclient` stage above; the -# Debian libraries libpq links (Kerberos, LDAP, SASL) come from apt below, so -# the build-time upgrade and the image scan see them. The copied binaries are -# outside dpkg: the scan does not see them and `apt-get upgrade` does not -# patch them, so a PostgreSQL client fix arrives by moving the `pgclient` -# pin. +# `postgresql-18-client` carries `pg_dump` and `pg_restore` for the nightly +# database dump, the pre-migration dump in the entrypoint and the +# `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the +# bundled database; a client dumps servers of its own major and older, so it +# also serves an install still on its own Postgres 16. # -# `ca-certificates` is the system trust store the OpenSSL-linked tools use -# (the Prisma schema engine, libpq) for a TLS connection to the database. -RUN set -eux; \ - export DEBIAN_FRONTEND=noninteractive; \ - apt-get update; \ - apt-get upgrade -y --no-install-recommends; \ - apt-get install -y --no-install-recommends \ - ca-certificates tzdata libgssapi-krb5-2 libldap2 libsasl2-2; \ - apt-get clean; \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* -COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/ -COPY --from=pgclient /out/lib/libpq.so.5 /usr/lib/postgresql/18/lib/libpq.so.5 -RUN echo /usr/lib/postgresql/18/lib > /etc/ld.so.conf.d/postgresql-18.conf && \ - ldconfig && \ - ! ldd /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore | grep -q "not found" -ENV PATH=/usr/lib/postgresql/18/bin:$PATH +# busybox, part of the base, is the shell the entrypoint runs under and +# carries `tar`/`gzip` for the GeoLite2 extraction and `addgroup`/`adduser`. +RUN apk add --no-cache nodejs-22 tzdata postgresql-18-client && \ + apk upgrade --no-cache && \ + mkdir -p /usr/local/bin +COPY --from=builder /app/.native-abi /tmp/.native-abi +RUN set -e; \ + read -r BUILD_ABI BUILD_GLIBC BUILD_NODE < /tmp/.native-abi; \ + rm -f /tmp/.native-abi; \ + RUN_ABI="$(node -p process.versions.modules)"; \ + RUN_GLIBC="$(node -p 'process.report.getReport().header.glibcVersionRuntime')"; \ + [ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI $RUN_ABI, builder $BUILD_ABI"; exit 1; }; \ + [ "$(printf '%s\n%s\n' "$BUILD_GLIBC" "$RUN_GLIBC" | sort -V | head -1)" = "$BUILD_GLIBC" ] || \ + { echo "ERROR: runner glibc $RUN_GLIBC is older than the builder's $BUILD_GLIBC"; exit 1; }; \ + echo "native ABI $RUN_ABI (builder $BUILD_NODE, runner $(node -v)); glibc $BUILD_GLIBC -> $RUN_GLIBC" WORKDIR /app ENV NODE_ENV=production @@ -162,9 +149,8 @@ ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ARG NEXT_PUBLIC_APP_BUILD_SHA ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs \ - --shell /usr/sbin/nologin nextjs +RUN addgroup -S -g 1001 nodejs && \ + adduser -S -u 1001 -G nodejs -h /home/nextjs -s /sbin/nologin nextjs # Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a # fresh named volume mounted here starts with that owner: Docker fills an @@ -327,8 +313,16 @@ RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \ # Install the migration CLI, its config dependency, and a pinned launcher for # the maintenance scripts already shipped in the standalone tree. The Prisma -# config loads dotenv from /app, so expose the isolated copy there before -# stripping npm/Corepack and their caches from the runtime surface. +# config loads dotenv from /app, so expose the isolated copy there. npm is +# installed for this one step and removed in the same layer, with its cache, +# so no package manager is left in the runtime surface. +# +# Wolfi's npm is major 12, which runs no dependency install scripts unless +# package.json lists the package under `allowScripts`. Three packages here +# have one: `@prisma/engines` downloads the schema engine `migrate deploy` +# runs, `prisma` checks its environment, and `esbuild` (under tsx) checks +# its binary. Without the field the install succeeds and ships no schema +# engine, so the check after this step asks the CLI for it. # # This install belongs to npm, not pnpm, so the `overrides` block in # `pnpm-workspace.yaml` does not reach it: a package pinned there for the app @@ -354,19 +348,29 @@ RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \ # npm install never reads. Rebuilding this install both ways confirms it: # without the two fields below npm resolves 1.19.11 and 1.2.0, with them it # resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0. -RUN mkdir -p /opt/prisma-cli && \ +RUN apk add --no-cache npm && \ + mkdir -p /opt/prisma-cli && \ cd /opt/prisma-cli && \ npm init -y && \ npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \ npm pkg set 'overrides.mysql2=^3.22.0' && \ npm pkg set 'overrides.@hono/node-server=^1.19.15' && \ npm pkg set 'overrides.valibot=^1.4.2' && \ + npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \ npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 && \ ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \ ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \ ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \ - rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \ - rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx + apk del --no-cache npm && \ + rm -rf /root/.npm /root/.cache && \ + sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v pnpm' + +# The schema engine the entrypoint's `migrate deploy` runs is in place. +# Look for the file before asking the CLI: a CLI that finds none downloads +# one, which would hide the missing install script here and fail offline. +RUN ls /opt/prisma-cli/node_modules/@prisma/engines/schema-engine-* >/dev/null && \ + node /opt/prisma-cli/node_modules/prisma/build/index.js --version 2>&1 | \ + grep -q "Schema Engine *: schema-engine-cli [0-9a-f]* (at " # Same proof for the intake-repair script under the launcher it is documented # with: its `pg` import resolves, so it reaches its own DATABASE_URL check. @@ -389,7 +393,7 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | # user so the runtime worker can WRITE it: a self-hoster who sets # MAXMIND_LICENSE_KEY on the published image has the databases fetched into # this directory at runtime (src/lib/geo/geolite2-fetch.ts) with no rebuild or -# manual mount. `tar`/`gzip` for the extraction are part of the Debian base. A +# manual mount. `tar`/`gzip` for the extraction come with busybox. A # read-only bind mount at this path (the bring-your-own-database route) still # works — the runtime fetch just fails soft and the mounted files stand. RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2 @@ -419,8 +423,8 @@ ENV HOSTNAME="0.0.0.0" # Five retries: a pool saturated for a minute during a boot storm makes the # probe's 2 s database check miss once or twice without marking the container # unhealthy (docker-compose.yml explains the numbers). The probe is Node's own -# fetch: the Debian base carries neither wget nor curl, and installing one for -# this line would add a TLS stack to the image's scan surface. A non-2xx +# fetch: it needs nothing beyond the runtime, the same probe works on any +# base, and no HTTP client has to be installed for one line. A non-2xx # answer, a refused connection and the 4 s abort all exit 1. HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \ CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] diff --git a/README.md b/README.md index c5f60968c..f605cbc78 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The App Store listing carries a small price, which exists to cover Apple's yearl ## Tech stack -Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Debian (glibc) Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). +Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage glibc Docker image (Debian build, Wolfi runtime). The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). ## Documentation diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 156a7333e..c602b9c12 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -10,7 +10,7 @@ already installed, longer if a TLS-fronted public hostname is in scope - **Docker 24+ with Compose v2.** The bundled `docker-compose.yml` pulls a multi-arch image from GitHub Container Registry (linux/amd64 and linux/arm64), so a Raspberry Pi 4 or 5 or an x86 VPS all work - unchanged. The image is Debian-based (glibc). Document thumbnails and + unchanged. The image runs on glibc (a Wolfi base). Document thumbnails and scanned-PDF rendering use a native renderer that runs on every arm64 board, the Pi 4 included, and on x86-64 CPUs with AVX2; on an older x86 CPU (Celeron/Atom-class NAS chips) it switches itself off and those two diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index 61911c6a9..ae4eb4b0f 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -224,7 +224,8 @@ describe("release container inputs", () => { const dockerfile = readRepoFile("Dockerfile"); const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? []; - expect(fromLines).toHaveLength(3); + // deps and builder; the runner is Wolfi (canvas-binary-hoist-guard). + expect(fromLines).toHaveLength(2); expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual( new Set([ "FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f", @@ -243,8 +244,11 @@ describe("release container inputs", () => { expect( dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g), ).toHaveLength(2); + // The runner installs npm for the Prisma CLI step only, removes it in the + // same layer, and fails the build if any package manager is left. + expect(dockerfile).toContain("apk del --no-cache npm"); expect(dockerfile).toContain( - "rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack", + "sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v pnpm'", ); expect(dockerfile).toContain("tsx@4.23.1"); expect(dockerfile).toContain( diff --git a/src/__tests__/canvas-binary-hoist-guard.test.ts b/src/__tests__/canvas-binary-hoist-guard.test.ts index 092be8a34..ca985e095 100644 --- a/src/__tests__/canvas-binary-hoist-guard.test.ts +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -61,20 +61,26 @@ describe("canvas binary hoist", () => { expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/); }); - it("builds every Node stage on the glibc Debian base", () => { - const froms = DOCKERFILE.match(/^FROM \S+/gm) ?? []; - const node = froms.filter((from) => from.startsWith("FROM node:")); - expect(node).toHaveLength(3); - for (const from of node) { - expect(from).toMatch(/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64}$/); - } - // The only other stage supplies the PostgreSQL client tools, built on - // the same Debian release so its binaries find their libraries. - expect(froms.filter((from) => !from.startsWith("FROM node:"))).toEqual([ + it("installs on Debian and runs on Wolfi, both glibc", () => { + const froms = DOCKERFILE.match(/^FROM \S+ AS \S+$/gm) ?? []; + expect(froms).toEqual([ expect.stringMatching( - /^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64}$/, + /^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS deps$/, + ), + expect.stringMatching( + /^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS builder$/, + ), + expect.stringMatching( + /^FROM cgr\.dev\/chainguard\/wolfi-base:latest@sha256:[0-9a-f]{64} AS runner$/, ), ]); + // The native packages pnpm picked in the builder must load in the + // runner: same module ABI, and a glibc no older than the builder's. + expect(stage("builder")).toContain("> /app/.native-abi"); + expect(stage("runner")).toContain( + '[ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI', + ); + expect(stage("runner")).toContain("is older than the builder's"); }); it("fails the build when the copied binary is another version", () => { diff --git a/src/__tests__/database-pin-is-watched.test.ts b/src/__tests__/database-pin-is-watched.test.ts index a9350eafd..84d2d4cea 100644 --- a/src/__tests__/database-pin-is-watched.test.ts +++ b/src/__tests__/database-pin-is-watched.test.ts @@ -40,32 +40,6 @@ describe("the bundled database pin", () => { } }); - it("ships pg_dump of the same minor the bundled database runs", () => { - const db = /^\s*image:\s*postgres:(\d+\.\d+)-alpine$/m.exec( - read("docker-compose.yml"), - ); - const client = - /^FROM postgres:(\d+\.\d+)-trixie@sha256:[0-9a-f]{64} AS pgclient$/m.exec( - read("Dockerfile"), - ); - expect(db, "the compose file pins the database").not.toBeNull(); - expect(client, "the Dockerfile has a pinned pgclient stage").not.toBeNull(); - // The two pins are one decision; a Dependabot PR moves them separately. - expect(client![1]).toBe(db![1]); - }); - - it("keeps a major bump of the client tools out of the Dockerfile watch too", () => { - const dependabot = read(".github/dependabot.yml"); - const docker = - /package-ecosystem:\s*"docker"\n([\s\S]*?)(?=\n\s*- package-ecosystem:)/.exec( - dependabot, - ); - expect(docker, "a docker ecosystem entry").not.toBeNull(); - expect(docker![1]).toMatch( - /dependency-name:\s*"postgres"[\s\S]{0,200}version-update:semver-major/, - ); - }); - it("lets a major version through only as a decision, never as an update PR", () => { const dependabot = read(".github/dependabot.yml"); // The first run of the compose ecosystem proposed 16.15 -> 18.6. A diff --git a/src/lib/documents/native-canvas-support.ts b/src/lib/documents/native-canvas-support.ts index 1006d68d9..1cdb407af 100644 --- a/src/lib/documents/native-canvas-support.ts +++ b/src/lib/documents/native-canvas-support.ts @@ -9,7 +9,7 @@ * restart loop (live incident: a Synology DS1520+ self-host, exit code 132 * ~18 s after every boot once the thumbnail backfill touched Skia). * - * The published image is Debian (glibc) and runs the glibc build. Under an + * The published image runs on glibc (Wolfi) and loads the glibc build. Under an * emulated AVX2-less Atom core that build drew a canvas and rendered * a PDF page to JPEG where the musl build died, but it still carries AVX2 code * and not every path it can take was exercised, so the x64 gate stays closed @@ -26,7 +26,7 @@ * fallback, and dies with SIGILL as soon as the module loads on an ARMv8.0 * core such as the Raspberry Pi 4's Cortex-A72 (#1311). The glibc arm64 build * uses neither and runs there, which is why the published image moved off - * Alpine. The musl branch stays for a host that runs the app on musl itself, + * musl. The musl branch stays for a host that runs the app on musl itself, * from source on Alpine or from an image built on it. * * Detection reads `/proc/cpuinfo` once on Linux: x64 requires the `avx2`