feat(docker): add Alpine image variants, full and slim (#840) - #959
Conversation
Debian stays the default tag. -alpine same engine set, 129 MB compressed -alpine-slim engines traded for size, 50 MB compressed Trivy 0.73.0: Debian 3 CRITICAL / 53 HIGH, Alpine 0 / 2. Current Debian image is 308 MB for comparison. Verified by running both images, not just building them: musl native modules load, both entrypoint paths work, PID 1 is the app as uid 1001, the sample DB seeds. buildx does amd64 and arm64. Oracle on -alpine is Thin-only, there's no musl Instant Client. -slim also drops DuckDB and sharp, and uses Alpine's nodejs package instead of the official image, which ships its binary unstripped. Why the prune covers both module trees, and why this is two files instead of one ARG, is in the Dockerfile comments.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…leaves out The first -alpine-slim build deleted Monaco's language workers from the staged public/monaco tree, on the reading that a SQL editor never consults the TypeScript language service. Measured in the built image: the query editor never mounts at all. editor.main.js bundles the json, css, html and typescript contributions, each mode chunk declares its worker stub as a hard AMD dependency (vs/jsonMode-<hash> requires ./json.worker-<hash>), and the loader resolves that graph when the editor loads rather than when a buffer of that language is opened, so one missing chunk rejects loader.init(). The image booted, served /login and seeded its sample database with no editor behind them, which is why the build stayed green. The workers stay, and the build now asserts it kept as many worker chunks as monaco-editor ships. Oracle Thin comes back for the same class of reason: the driver is pure JavaScript at 1.3 MB, which is not worth an engine in a 57 MB image. Only the native Thick addons stay out of the Alpine variants, and only because no musl Instant Client exists to load them. DuckDB stays out, since it is four packages ending in a ~70 MB libduckdb.so, but its absence now answers for itself. openDuckDBClient reached the driver through a bare dynamic import outside the try/catch that produces this provider's failures, so opening a DuckDB connection on that tag put Failed to load external module @duckdb/node-api-<hash>: Cannot find module ... Require stack: - /app/.next/server/chunks/[externals]_@duckdb_node-api... in a browser toast. describeDriverAbsence translates a resolution failure naming that package into a sentence naming the tags that do ship it, and re-raises every other import failure untouched: a corrupt binding is not "DuckDB is not in this image". Also adds the two new root Dockerfiles to the standalone payload deny-list. It held a literal `Dockerfile`, so it covered one of three, and the same list serves the release tarballs, .deb/.rpm, snap and the npx cache. Measured amd64 with docker save | gzip on this commit: 310 MB published Debian, 128 MB -alpine, 57 MB -alpine-slim.
The two Alpine Dockerfiles landed as files nothing built. This wires them into the pipeline that already exists, rather than beside it. docker-build-push.yml grows a variant matrix. Everything that is the same for all three - the gates, the version, the platform list, whether the Docker Hub mirror is in play - moves to a `prepare` job, so ESLint and tsc run once per commit instead of three times. The suffix is written into each tag rather than set through metadata-action's `flavor`, which would have to carry an empty suffix for the default image, and the gha cache is scoped per variant because glibc and musl builds share almost no layers and one scope has them evicting each other. The matrix legs run in parallel, so the wall clock is unchanged. Channel E2E now runs per variant, and that is the point rather than a convenience: e2e/embedded-samples.spec.ts waits on .monaco-editor, and it is what would have caught an image that booted, served /login and seeded its sample database with no query editor. Every check a single-image pipeline runs was green on that image. security-scan.yml scans all three published tags daily, each under its own SARIF category and SBOM artifact name. Leaving the Alpine tags unscanned would publish a security claim with nothing measuring it, and the slim variant's runtime base is Alpine's own nodejs package rather than the official image, a third OS layer with its own answer. packaging-image-variants.test.ts reads the variant list off the repo root, so a fourth Dockerfile inherits every invariant and cannot land unpublished or untested. It also carries the Monaco regression as a guard on the instruction that caused it, checked at command granularity rather than per RUN block, and verified against the pre-fix file. Tag tables in DOCKERHUB.md and docs/DISTRIBUTION.md, plus the README registry note, now describe the three bases and what -alpine-slim trades away.
Next's output file tracing walks the repository root, so .next/standalone carried src/, scripts/, tests/ config, the lockfile and the Dockerfiles, and every image unpacked that onto /app. The published Debian image has /app/src and /app/Dockerfile in it today. prune-standalone-payload.sh already removed all of it for the release tarballs, .deb/.rpm, snap and the npx cache (libredb#124); each Dockerfile now runs the same script in its builder stage, so one deny-list serves every artifact family and a new root file leaves them all through one edit. It runs in the builder because an rm after a COPY deletes in a later layer while leaving every byte in the layer the COPY created. Three gaps in that deny-list surfaced once the images were reading it: codecov.yml, distribution/ (scripts and the showcase generator read the inventory; the server reads src/lib/distribution/channels.generated.ts), and the base-path, channel and smoke Playwright harnesses, where only the bare playwright.config.ts was listed. public/screenshots goes too, in the default image as it already did in the Alpine ones: 4.4 MB of README artwork that no running container serves, since src/app/layout.tsx points social previews at raw.githubusercontent.com. It is not a payload-root entry, so the deny-list cannot reach it and each Dockerfile names it. Deletes five create-next-app leftovers from public/ - next, vercel, window, globe and file .svg - with no reference anywhere in the repository. After this, /app is exactly the runtime keep-list in all three: LICENSE, README.md, data, node_modules, package.json, public, seed-assets, server.js. Measured amd64 with docker save | gzip: Debian 310 -> 292 MB, -alpine 128 -> 125 MB, -alpine-slim unchanged at 57 MB since it already pruned. All three rebuilt and driven in a browser: the editor mounts, a query against the seeded sample returns, and the console is clean.
Every image carried the binaries of the OTHER libc. Neither @duckdb/node-bindings-<platform>-<arch>[-musl] package declares a libc field, so bun installs the glibc AND the musl one whatever the stage runs on; sharp ships the same way; and better-sqlite3 13 carries eight prebuilds plus the 9.9 MB SQLite amalgamation it would compile from if it ever had to. Measured in the image published today: 71 MB of musl DuckDB bindings, 19 MB of musl libvips and six unreachable prebuilds, none of which any process in a glibc image can open. The Alpine files already pruned their half. The default image pruned nothing, so this adds the mirror-image step to it, keyed on $(node -p process.arch) rather than a literal so the arm64 leg of the same manifest keeps working. `*-linux-*` does not match `*-linuxmusl-*` - "linux-" is not a prefix of "linuxmusl-" - so the two sharp patterns are each other's complement rather than overlapping. It lands twice per image: `next build` writes a second traced copy of those packages into .next/standalone/node_modules, and the runner unpacks that onto the same /app the explicit COPYs land in, so the loop walks both trees. That is why the default image drops 892 MB to 649 MB rather than the ~115 MB one tree holds. Compressed, amd64: 310 MB published today, 203 MB here. -alpine now keys its surviving prebuild on the arch too. Keeping both musl files was defended as arch-agnosticism, but each leg of the manifest is built for its own arch, so the other one was 2.3 MB nothing in that image could open. oracledb keeps every platform's addon deliberately: the default tag is the only one where Thick mode can be turned on, the whole build/ directory is ~3 MB, and the package resolves the addon at runtime from its own __dirname. All three rebuilt and driven: better-sqlite3, DuckDB (a real `select version()`), oracledb Thin, sharp and the embedded LibreDB store all load on the default and -alpine images; DuckDB and sharp are absent on -alpine-slim as intended; the editor mounts and a query against the seeded sample returns 1000 on all three.
|
Measured both images before merging and
I pushed four commits to your branch rather than send you round again:
The PR body is rewritten to describe the three variants as they now stand. Thanks for measuring the bases in the issue first, that is what made the case. |
|
Measured the published image against the three this branch builds. All four on amd64, 2026-09-18, same machine:
Three things to read out of it. The default tag loses 107 MB compressed and no functionality. The package count is the check: 53 before, 53 after. What left is 111 MB of and now contains Size work is not security work. The default tag scores exactly what it scored before, 3 / 53, because the base is the base. That is the whole argument for the Alpine variants, and it is why the issue asked for them rather than for a smaller Debian image.
The Debian numbers are not a reason to move the default tag. It stays Debian, Oracle Thick mode stays possible there, and the two Alpine tags are the answer for anyone who wants the OS surface gone. |
…unbook about them fail-fast is off on the variant matrix on purpose: an -alpine-slim that will not build must not cancel the image every user pulls. The cost is that a red leg can sit beside two that already pushed their tags, and the recovery for that was bad. Re-dispatching the workflow rebuilt all three and re-pushed version tags the Docker Hub mirror has frozen - its immutability rule is semver-scoped and `0.16.1-alpine` matches it - and buildx exports every tag in one step, so the rejected mirror tag failed the whole job with GHCR already written. A single failed variant therefore cost close to a new version number. workflow_dispatch grows a `variant` input, and the matrix moves into the `prepare` job as a table the build and Channel E2E jobs both consume through fromJSON. GitHub cannot filter a literal matrix by an input, which is the only reason the list is no longer written as `strategy.matrix`; the unit test reads the table back out of that script and still checks it against the Dockerfiles at the repo root. An unrecognized variant stops the run rather than building none and reporting success. The chart dispatch is gated on the same input. A dispatch that rebuilt ONE leg must not re-release a chart whose contents did not change, because libredb#167 then blocks the retry a real chart change needs. The cut-release skill learns three things: `Docker Build and Push` is six jobs rather than one answer, so count the legs instead of reading the run's conclusion; Phase 6 verifies `latest<suffix>` against `<version><suffix>` for all three variants rather than the default tag alone, since a broken variant would otherwise ship with the release reading as complete; and the recovery table carries the single-variant re-dispatch with the reason it exists.
Adds two Alpine image variants beside the Debian default, and trims all three.
Description
Dockerfile(Debian trixie-slim) stays the default tag and its behaviour is unchanged. Two files join it, because Dependabot's docker ecosystem cannot follow an ARG-interpolatedFROMand a variant that exists for its CVE posture has to keep receiving base bumps:node:26.8.2-trixie-slim-alpinenode:26.8.2-alpine3.23-alpine-slimalpine:3.23+ Alpine's ownnodejsTrivy 0.73.0 on the bases: Debian 3 CRITICAL / 52 HIGH,
node:26-alpine0 / 2. The newestnode:26-trixie-slimscores the same as ours, so those findings are the distro's rather than a stale pin.The default image also gets 35% smaller without giving anything up, because three things were shipping that nothing could use:
libcfield, so bun installs both whatever the stage runs on. It lands twice per image, becausenext buildwrites a second traced copy into.next/standalone/node_modules./appcarriedsrc/,scripts/, the lockfile, the tooling configs and the Dockerfiles. Each file now runsscripts/lib/prune-standalone-payload.sh, the deny-list the release tarballs,.deb/.rpm, snap and the npx cache already share (Trim the standalone payload: Next output tracing pulls repo-root extras #124).public/screenshots, which no running container serves./appis now exactly the runtime in all three:LICENSE README.md data node_modules package.json public seed-assets server.js.Type of Change
Related Issue
Closes #840
Changes Made
Dockerfile.alpine,Dockerfile.alpine-slim: the two variants, each documenting what it gives up and why.Dockerfile: payload prune, native-payload prune, screenshots removed. No change to its base, its entrypoint or its engine set..github/workflows/docker-build-push.yml: a variant matrix. The gates, the version, the platform list and the Docker Hub decision move to apreparejob so they run once per commit rather than three times. The suffix is written per tag rather than throughflavor, and the gha cache is scoped per variant because glibc and musl builds share almost no layers. Channel E2E runs per variant..github/workflows/security-scan.yml: the daily Trivy scan covers all three tags, each with its own SARIF category and SBOM artifact.src/lib/db/providers/sql/duckdb/client.ts:describeDriverAbsenceturns "this deployment has no DuckDB driver" into a sentence naming the tags that do ship it, and re-raises every other import failure untouched.scripts/lib/prune-standalone-payload.sh: covers all three Dockerfiles,codecov.yml,distribution/and every Playwright harness.DOCKERHUB.mdanddocs/DISTRIBUTION.md, the README registry note,docs/providers/oracle.md(Thick mode is default-tag only),docs/providers/duckdb.md(not in-alpine-slim).public/with no reference anywhere in the repository.What
-alpine-slimgives upDuckDB, because the driver is four packages ending in a ~70 MB
libduckdb.so, and sharp/libvips. Everything else stays, including Oracle Thin, the agent, the embedded LibreDB store and the SQLite sample. Oracle Thick mode needs Instant Client, which has no musl build, so it is reachable on the default tag only.Testing
All three images were built from scratch and driven in a browser, not only built: login, the object tree, a query against the seeded sample, EXPLAIN, AI Explain, an agent run, the ER diagram and a PNG export.
better-sqlite3, DuckDB (a realselect version()), Oracle Thin, sharp and the embedded LibreDB store all load on the default and-alpineimages; DuckDB and sharp are absent on-alpine-slimby design and say so.tests/unit/packaging-image-variants.test.tsreads the variant list off the repo root, so a fourth Dockerfile inherits every invariant and cannot land unpublished or untested.Test Environment
-alpine-slim)Checklist
docs/providers/duckdb.mdanddocs/providers/oracle.mdupdated alongside the DuckDB client change, withtests/unit/duckdb-driver-absence.test.tscovering the new seamAdditional Notes
The Alpine variants are @hbasria's work. Review found one blocking defect in
-alpine-slimand the commits after the first one fix it and wire the variants into the pipeline; the thread has the detail.