Skip to content

feat(docker): add Alpine image variants, full and slim (#840) - #959

Merged
cevheri merged 6 commits into
libredb:mainfrom
hbasria:feat/alpine-image
Sep 18, 2026
Merged

cevheri merged 6 commits into
libredb:mainfrom
hbasria:feat/alpine-image

Conversation

@hbasria

@hbasria hbasria commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

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-interpolated FROM and a variant that exists for its CVE posture has to keep receiving base bumps:

Tag suffix Base Engines Compressed (amd64)
none node:26.8.2-trixie-slim all, and the only one where Oracle Thick mode can be layered on 203 MB (310 MB today)
-alpine node:26.8.2-alpine3.23 all, Oracle Thin only 124 MB
-alpine-slim alpine:3.23 + Alpine's own nodejs all except DuckDB 57 MB

Trivy 0.73.0 on the bases: Debian 3 CRITICAL / 52 HIGH, node:26-alpine 0 / 2. The newest node:26-trixie-slim scores 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:

  • the native payload of the other libc: 71 MB of musl DuckDB bindings, 19 MB of musl libvips and six unreachable better-sqlite3 prebuilds. Neither DuckDB bindings package declares a libc field, so bun installs both whatever the stage runs on. It lands twice per image, because next build writes a second traced copy into .next/standalone/node_modules.
  • the repository itself: output file tracing sweeps the repo root, so /app carried src/, scripts/, the lockfile, the tooling configs and the Dockerfiles. Each file now runs scripts/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).
  • 4.4 MB of README artwork under public/screenshots, which no running container serves.

/app is now exactly the runtime in all three: LICENSE README.md data node_modules package.json public seed-assets server.js.

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Performance improvement

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 a prepare job so they run once per commit rather than three times. The suffix is written per tag rather than through flavor, 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: describeDriverAbsence turns "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.
  • Docs: tag tables in DOCKERHUB.md and docs/DISTRIBUTION.md, the README registry note, docs/providers/oracle.md (Thick mode is default-tag only), docs/providers/duckdb.md (not in -alpine-slim).
  • Deletes five create-next-app leftovers from public/ with no reference anywhere in the repository.

What -alpine-slim gives up

DuckDB, 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

  • I have tested this locally
  • I have added/updated tests
  • All existing tests pass

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 real select version()), Oracle Thin, sharp and the embedded LibreDB store all load on the default and -alpine images; DuckDB and sharp are absent on -alpine-slim by design and say so.

tests/unit/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.

Test Environment

  • LibreDB Studio Version: 0.16.0
  • Browser: Chrome (DevTools protocol)
  • OS: Linux, amd64
  • Node.js/Bun Version: Bun 1.4.2, Node 26.8.2 (Alpine's 24.18.1 on -alpine-slim)
  • Database Type: embedded SQLite and LibreDB samples; DuckDB and Oracle probed per variant

Checklist

  • My code follows the project's code style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation accordingly
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • The required CI test job passes the 100% line-coverage gate
  • Provider triad: docs/providers/duckdb.md and docs/providers/oracle.md updated alongside the DuckDB client change, with tests/unit/duckdb-driver-absence.test.ts covering the new seam
  • Any dependent changes have been merged and published

Additional Notes

The Alpine variants are @hbasria's work. Review found one blocking defect in -alpine-slim and the commits after the first one fix it and wire the variants into the pipeline; the thread has the detail.

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

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cevheri cevheri linked an issue Sep 18, 2026 that may be closed by this pull request
@cevheri
cevheri self-requested a review September 18, 2026 14:28
@cevheri cevheri added deployment PaaS / one-click deployment platform integrations and listings security Supply-chain, auth, or hardening work dependencies Dependency version updates docker Container base image dependencies core-capabilities labels Sep 18, 2026
@cevheri cevheri self-assigned this Sep 18, 2026
…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.
@cevheri

cevheri commented Sep 18, 2026

Copy link
Copy Markdown
Member

Measured both images before merging and -alpine holds up: musl prebuilds load, DuckDB runs a real query (v1.5.5), oracledb.thin === true and a connection attempt reaches the network, agent mode and the ER diagram work. 124 MB compressed here on amd64, close to your 129.

-alpine-slim had one blocking defect: the query editor never mounts. Deleting *.worker*.js from public/monaco takes the editor with it. editor.main.js bundles the json, css, html and typescript contributions, each mode chunk requires its worker stub as a hard AMD dependency, and the loader resolves that graph when the editor loads rather than when a buffer of that language is opened, so the first 404 rejects loader.init(). The image still booted, served /login and seeded the sample database, which is why every check was green. Control: copying the workers back into the running container brought the editor back.

I pushed four commits to your branch rather than send you round again:

  • the workers stay, and the build asserts it kept as many worker chunks as monaco-editor ships. Oracle Thin comes back too, since 1.3 MB is not worth an engine. DuckDB stays out but now answers with a sentence naming the tags that ship it.
  • the variants are wired into the pipeline: build matrix, Trivy per tag, and Channel E2E per variant. That last one is what would have caught this, since the spec waits on .monaco-editor.
  • your prune idea applied to the default image too: it was carrying 71 MB of musl DuckDB bindings, 19 MB of musl libvips and the whole repo tree. 310 MB to 203 MB compressed, no feature lost.

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.

@cevheri

cevheri commented Sep 18, 2026

Copy link
Copy Markdown
Member

Measured the published image against the three this branch builds. All four on amd64, 2026-09-18, same machine: docker save | gzip -c | wc -c for the compressed column, find / -xdev -type f for the file counts, Trivy 0.73.0 with the same pinned digest security-scan.yml uses.

:latest (0.16.0, published) this PR, default tag -alpine -alpine-slim
Compressed 310 MB 203 MB 124 MB 57 MB
Uncompressed 925 MB 649 MB 389 MB 190 MB
Layers 32 32 29 22
Files in the image 9,239 8,517 3,947 3,216
Files under /app 4,137 3,415 3,386 2,978
/app 311 MB 184 MB 182 MB 91 MB
/app/node_modules 235 MB 124 MB 122 MB 31 MB
npm packages under /app 53 53 53 51
Trivy CRITICAL / HIGH 3 / 53 3 / 53 0 / 2 0 / 0
of those, with a fix available 13 13 2 0

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 node_modules that no process in a glibc image could open (musl DuckDB bindings, musl libvips, six unreachable better-sqlite3 prebuilds, the SQLite amalgamation), plus the repository itself. /app used to contain:

Dockerfile  artifacthub-repo.yml  bin  biome.json  bun.lock  bunfig.toml  codecov.yml
components.json  database-compose.yml  deploy  distribution  docker
docker-compose.example.yml  docker-entrypoint.sh  eslint.config.mjs  fly.toml  knip.json
next.config.ts  playwright*.config.ts  postcss.config.mjs  scripts  snap
sonar-project.properties  src  tsconfig*.json  tsup.config.ts

and now contains LICENSE README.md data node_modules package.json public seed-assets server.js.

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.

-alpine-slim scores 0 / 0 where -alpine scores 0 / 2. The two are one OpenSSL CVE in the official Node image's layer (CVE-2026-14456, libcrypto3/libssl3 3.5.7-r0, fixed upstream in 3.5.8-r0) and they clear on the next node:26-alpine rebuild. The slim variant does not carry them because it runs Alpine's own nodejs package on a plain alpine:3.23 base.

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.
@cevheri
cevheri merged commit 1039c1d into libredb:main Sep 18, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core-capabilities dependencies Dependency version updates deployment PaaS / one-click deployment platform integrations and listings docker Container base image dependencies security Supply-chain, auth, or hardening work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Create Distroless or Alpine-based Docker Image

2 participants