diff --git a/.github/workflows/publish-to-pkg.pr.new.yml b/.github/workflows/publish-to-pkg.pr.new.yml index 79279257c2..d9b4e94c19 100644 --- a/.github/workflows/publish-to-pkg.pr.new.yml +++ b/.github/workflows/publish-to-pkg.pr.new.yml @@ -137,3 +137,161 @@ jobs: './packages/cli' \ './packages/core' \ './packages/prompts' + + # Build and push a preview Docker image from the pkg.pr.new build so the image + # can be verified before a real release. Tagged `pr-`; never `latest`. + # See docker/Dockerfile and docs/guide/docker.md. + publish-docker-preview: + if: >- + github.repository == 'voidzero-dev/vite-plus' && + contains(github.event.pull_request.labels.*.name, 'pkg.pr.new') + name: Docker preview image (${{ matrix.variant }}) + runs-on: ubuntu-latest + needs: publish + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - variant: debian + dockerfile: docker/Dockerfile + tag_suffix: '' + - variant: alpine + dockerfile: docker/Dockerfile.alpine + tag_suffix: '-alpine' + env: + IMAGE: ghcr.io/voidzero-dev/vite-plus + TAG: pr-${{ github.event.pull_request.number }}${{ matrix.tag_suffix }} + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Builds from the pkg.pr.new packages for this PR (VP_PR_VERSION). The + # platform packages must exist first, hence `needs: publish`. + # amd64-only: this throwaway preview avoids the slow arm64 QEMU leg; arm64 + # is covered by the release build and the test-install-sh-arm64 job. + - name: Build and push preview image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: docker + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: true + tags: ${{ env.IMAGE }}:${{ env.TAG }} + build-args: | + VP_PR_VERSION=${{ github.event.pull_request.number }} + provenance: false + + - name: Summary + run: | + { + echo "### Docker preview image (${{ matrix.variant }})" + echo "" + echo '```bash' + echo "docker pull ${IMAGE}:${TAG}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # Post (or update) a single sticky PR comment with the preview image tags after + # both variants publish successfully. Re-runs reuse the same comment via the + # hidden marker instead of creating a new one. + comment-docker-preview: + if: >- + github.repository == 'voidzero-dev/vite-plus' && + contains(github.event.pull_request.labels.*.name, 'pkg.pr.new') + name: Comment Docker preview + runs-on: ubuntu-latest + needs: publish-docker-preview + permissions: + contents: read + packages: read + pull-requests: write + env: + IMAGE: ghcr.io/voidzero-dev/vite-plus + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Measure image sizes + id: sizes + run: | + size() { + docker pull -q "$1" >/dev/null + numfmt --to=si --suffix=B "$(docker image inspect "$1" --format '{{.Size}}')" + } + { + echo "debian=$(size "${IMAGE}:pr-${PR_NUMBER}")" + echo "alpine=$(size "${IMAGE}:pr-${PR_NUMBER}-alpine")" + } >> "$GITHUB_OUTPUT" + + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + DEBIAN_SIZE: ${{ steps.sizes.outputs.debian }} + ALPINE_SIZE: ${{ steps.sizes.outputs.alpine }} + with: + script: | + const image = process.env.IMAGE; + const pr = context.payload.pull_request.number; + const marker = ''; + // Built as a line array (not a template literal) so the fenced code + // blocks don't collide with the YAML block-scalar indentation. + const body = [ + marker, + '## 🐳 Docker preview images', + '', + "Built from this PR's pkg.pr.new build:", + '', + '| Image | Size |', + '| --- | --- |', + `| \`${image}:pr-${pr}\` | ${process.env.DEBIAN_SIZE} |`, + `| \`${image}:pr-${pr}-alpine\` | ${process.env.ALPINE_SIZE} |`, + '', + '```bash', + `docker pull ${image}:pr-${pr}`, + `docker pull ${image}:pr-${pr}-alpine`, + '```', + '', + 'Quick check:', + '', + '```bash', + `docker run --rm ${image}:pr-${pr} vp --version`, + '```', + '', + 'See [docs/guide/docker.md](https://github.com/voidzero-dev/vite-plus/blob/main/docs/guide/docker.md) for usage.', + ].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + body, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca91858e8a..8b5600c236 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,3 +232,70 @@ jobs: • macOS/Linux: `curl -fsSL https://vite.plus | bash` • Windows: `irm https://vite.plus/ps1 | iex` embed-url: https://github.com/${{ github.repository }}/releases/tag/v${{ env.VERSION }} + + # Build and push the official toolchain Docker image to GHCR after the npm + # release is published (the image installs vp from npm, so the version must + # exist first). See docker/Dockerfile and docs/guide/docker.md. + publish-docker: + name: Publish Docker image (${{ matrix.variant }}) + runs-on: ubuntu-latest + needs: [check, Release] + if: needs.check.outputs.version_changed == 'true' + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + # Debian (glibc) is the default: tags get no suffix. + - variant: debian + dockerfile: docker/Dockerfile + suffix: '' + # Alpine (musl): tags get the -alpine suffix. + - variant: alpine + dockerfile: docker/Dockerfile.alpine + suffix: '-alpine' + env: + VERSION: ${{ needs.check.outputs.version }} + IMAGE: ghcr.io/voidzero-dev/vite-plus + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE }} + flavor: | + latest=false + suffix=${{ matrix.suffix }} + tags: | + type=semver,pattern={{version}},value=${{ env.VERSION }} + type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }} + type=semver,pattern={{major}},value=${{ env.VERSION }} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: docker + file: ${{ matrix.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VP_VERSION=${{ env.VERSION }} + provenance: false diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000..f2cf3a25ef --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 +# +# Official Vite+ toolchain image. +# +# Bundles the `vp` CLI for the build, CI, and development phases. This is NOT a +# production runtime image: it ships the full toolchain (vite, rolldown, vitest, +# oxlint, ...) and is meant for use as a build stage, CI image, or devcontainer. +# +# For production, use the documented multi-stage pattern (see docs/guide/docker.md) +# where this image builds the app and the exact Node.js resolved from +# `.node-version` is copied into a small, vp-free runtime stage. + +FROM debian:bookworm-slim + +LABEL org.opencontainers.image.source="https://github.com/voidzero-dev/vite-plus" \ + org.opencontainers.image.description="Vite+ toolchain image (vp CLI) for build, CI, and development" \ + org.opencontainers.image.licenses="MIT" + +# Version of vp to install. Override at build time: +# docker build --build-arg VP_VERSION=1.4.2 . +ARG VP_VERSION=latest + +# Optional: build a preview image from a pkg.pr.new build instead of npm. +# Set to a PR number or commit SHA; when set it overrides VP_VERSION. +# docker build --build-arg VP_PR_VERSION=1569 . +ARG VP_PR_VERSION= + +# Toolchain image: include git and a C/C++ build toolchain so native addons +# (for example better-sqlite3 or sharp) can compile during `vp install`. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + build-essential \ + python3 \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --shell /bin/bash vp + +# Run as a non-root user by default (mirrors oven/bun's `bun` and Deno's `deno`). +USER vp + +ENV VP_HOME=/home/vp/.vite-plus \ + PATH=/home/vp/.vite-plus/bin:$PATH + +# Install the vp global CLI. The installer downloads the platform package from +# npm (or from pkg.pr.new when VP_PR_VERSION is set). Node.js itself is +# provisioned per-project by vp at build time, honoring `.node-version` / +# `engines.node` / `devEngines.runtime`. +# +# The installer pre-provisions a default Node.js (~190 MB). Drop it: each project +# downloads its own pinned Node at build time, so the default is dead weight in a +# builder image. The node/npm/npx shims remain and fetch the right version on +# first use. +# +# Keep this install line in sync with docker/Dockerfile.alpine. +RUN curl -fsSL https://vite.plus | VP_VERSION="${VP_VERSION}" VP_PR_VERSION="${VP_PR_VERSION}" bash \ + && vp --version \ + && rm -rf "$VP_HOME/js_runtime" + +WORKDIR /app diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine new file mode 100644 index 0000000000..7fc3420b3d --- /dev/null +++ b/docker/Dockerfile.alpine @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 +# +# Alpine (musl) variant of the official Vite+ toolchain image. +# +# Smaller base than the Debian image, but note the tradeoffs: +# - Vite+ installs Node.js from the unofficial musl builds +# (unofficial-builds.nodejs.org), which are NOT PGP-signed. +# - Some native addons need musl prebuilds or source compilation. +# - A musl Node.js binary only runs on a musl base, so pair this builder with +# an Alpine runtime stage (see docs/guide/docker.md). +# +# Prefer the Debian image (ghcr.io/voidzero-dev/vite-plus) unless you +# specifically need Alpine/musl. + +FROM alpine:3.21 + +LABEL org.opencontainers.image.source="https://github.com/voidzero-dev/vite-plus" \ + org.opencontainers.image.description="Vite+ toolchain image (vp CLI, Alpine/musl) for build, CI, and development" \ + org.opencontainers.image.licenses="MIT" + +# Version of vp to install. Override at build time: +# docker build --build-arg VP_VERSION=1.4.2 -f docker/Dockerfile.alpine . +ARG VP_VERSION=latest + +# Optional: build a preview image from a pkg.pr.new build instead of npm. +ARG VP_PR_VERSION= + +# build-base + python3 let native addons compile from source on musl. +RUN apk add --no-cache \ + bash \ + ca-certificates \ + curl \ + git \ + tar \ + build-base \ + python3 \ + && adduser -D -s /bin/bash vp + +# Run as a non-root user by default (mirrors oven/bun's `bun` and Deno's `deno`). +USER vp + +ENV VP_HOME=/home/vp/.vite-plus \ + PATH=/home/vp/.vite-plus/bin:$PATH + +# Install the vp global CLI (musl build), then drop the pre-provisioned default +# Node.js: each project provisions its own pinned Node at build time, so the +# default is dead weight. The node/npm/npx shims remain and fetch the right +# version on first use. +# +# Keep this install line in sync with docker/Dockerfile. +RUN curl -fsSL https://vite.plus | VP_VERSION="${VP_VERSION}" VP_PR_VERSION="${VP_PR_VERSION}" bash \ + && vp --version \ + && rm -rf "$VP_HOME/js_runtime" + +WORKDIR /app diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ba764c9e2f..dcf66686e7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -72,6 +72,7 @@ const guideSidebar = [ items: [ { text: 'IDE Integration', link: '/guide/ide-integration' }, { text: 'CI', link: '/guide/ci' }, + { text: 'Docker', link: '/guide/docker' }, { text: 'Commit Hooks', link: '/guide/commit-hooks' }, { text: 'Monorepo Guide', link: '/guide/monorepo' }, { text: 'Troubleshooting', link: '/guide/troubleshooting' }, diff --git a/docs/guide/docker.md b/docs/guide/docker.md new file mode 100644 index 0000000000..a947c3c0f6 --- /dev/null +++ b/docs/guide/docker.md @@ -0,0 +1,228 @@ +# Docker + +Vite+ publishes an official Docker image that bundles the `vp` CLI for the +**build, CI, and development** phases. + +``` +ghcr.io/voidzero-dev/vite-plus +``` + +The image is a toolchain image, not a production runtime image. Because `vp` +already reads your project's pinned Node.js version (`.node-version`, +`engines.node`, or `devEngines.runtime`) and downloads that exact version, you do +not need a Node-version-specific base image: one image builds any project against +its own Node. + +For production, you do not ship this image. Instead you use a multi-stage build +where this image builds the app, and the exact Node.js it resolved is copied into +a small, vp-free runtime image. That keeps the deployed image small while +matching your project's Node version exactly. + +## Image tags + +Tags track the `vp` version: + +| Tag | Meaning | +| -------------------------------------------------------- | -------------- | +| `ghcr.io/voidzero-dev/vite-plus:latest` | Latest release | +| `ghcr.io/voidzero-dev/vite-plus:` | Latest major | +| `ghcr.io/voidzero-dev/vite-plus:.` | Latest minor | +| `ghcr.io/voidzero-dev/vite-plus:..` | Exact version | + +Pin an exact tag (or a digest) for reproducible builds. The image is published +for `linux/amd64` and `linux/arm64` and runs as a non-root user by default. + +Browse all published versions and digests on the [GitHub package page](https://github.com/voidzero-dev/vite-plus/pkgs/container/vite-plus). + +The default image is Debian (glibc). An Alpine (musl) variant is published under +the same versions with an `-alpine` suffix (`:latest-alpine`, +`:-alpine`, and so on). See [Alpine variant](#alpine-musl-variant) for +when to use it and its tradeoffs. + +## Production: SSR / Node-server app + +For apps that run Node.js in production (SvelteKit, Nuxt, a custom Vite SSR +server, and so on), build with the toolchain image and copy the resolved Node.js +and the built app into a slim runtime stage: + +```dockerfile [Dockerfile] +# syntax=docker/dockerfile:1 + +# --- build stage: the official Vite+ toolchain image --- +FROM ghcr.io/voidzero-dev/vite-plus:latest AS build +WORKDIR /app + +# Install dependencies first so this layer is cached across source changes. +COPY --chown=vp:vp package.json pnpm-lock.yaml .node-version ./ +RUN vp install --frozen-lockfile + +# Build. vp reads .node-version and provisions that exact Node.js automatically. +COPY --chown=vp:vp . . +RUN vp build + +# Export the exact resolved Node.js binary for the runtime stage. +RUN cp "$(vp env which node | head -1)" /tmp/node + +# --- deps stage: production-only dependencies --- +# A separate, fresh `--prod` install so devDependencies (including the vite-plus +# toolchain) are excluded. Running `--prod` over the full install above would not +# prune the already-installed devDependencies. +FROM ghcr.io/voidzero-dev/vite-plus:latest AS deps +WORKDIR /app +COPY --chown=vp:vp package.json pnpm-lock.yaml .node-version ./ +RUN vp install --frozen-lockfile --prod + +# --- runtime stage: small, glibc, no vp --- +FROM debian:bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# The exact Node.js from .node-version (official, signature-verified build). +COPY --from=build /tmp/node /usr/local/bin/node + +COPY --from=build /app/dist ./dist +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/package.json ./ + +USER nobody +EXPOSE 3000 +CMD ["node", "dist/server.js"] +``` + +The deployed image contains only Node.js plus your app and production +dependencies, and matches `.node-version` exactly. It is much smaller than the +default `node:*` image; see the distroless tip below for the smallest result. + +::: warning Prune production dependencies in a separate stage +Install production dependencies in their own `deps` stage as shown. Running +`vp install --prod` after a full `vp install` in the same stage does not remove +the already-installed devDependencies, so the `vite-plus` toolchain would be +copied into the runtime image. If your server bundle is fully self-contained (no +un-bundled runtime dependencies), you can skip copying `node_modules` entirely. +::: + +::: tip Smaller still +For a shell-less, minimal-CVE runtime, swap the runtime base for distroless +(`gcr.io/distroless/cc`) and keep an `ENTRYPOINT` in vector form. It is glibc +based, so the copied Node.js binary remains compatible. +::: + +## Production: static SPA / SSG + +A static site needs no Node.js at runtime; serve the build output with any static +server: + +```dockerfile [Dockerfile] +FROM ghcr.io/voidzero-dev/vite-plus:latest AS build +WORKDIR /app +COPY --chown=vp:vp package.json pnpm-lock.yaml .node-version ./ +RUN vp install --frozen-lockfile +COPY --chown=vp:vp . . +RUN vp build + +FROM nginx:alpine AS runtime +COPY --from=build /app/dist /usr/share/nginx/html +``` + +## Continuous integration + +Use the image directly in container-based CI (GitLab CI, Buildkite, CircleCI, +Jenkins, and others): + +```yaml [.gitlab-ci.yml] +build: + image: ghcr.io/voidzero-dev/vite-plus:latest + script: + - vp install --frozen-lockfile + - vp check + - vp test + - vp build +``` + +On GitHub Actions, prefer [`setup-vp`](./ci) instead of the image. + +## Devcontainers + +Use the image as a ready-to-go development container with the toolchain +preinstalled: + +```jsonc [.devcontainer/devcontainer.json] +{ + "image": "ghcr.io/voidzero-dev/vite-plus:latest", +} +``` + +## Ad-hoc usage + +Run any `vp` command against a project without installing vp on your machine: + +```bash +docker run --rm -it -v "$PWD:/app" -w /app ghcr.io/voidzero-dev/vite-plus vp build +``` + +## Alpine (musl) variant + +The `-alpine` tags are a musl build for teams that standardize on Alpine. They +produce the smallest runtime image, but come with tradeoffs: + +- Vite+ installs Node.js from the **unofficial musl builds** + (`unofficial-builds.nodejs.org`), which are **not PGP-signed** (the Debian + image uses the official, signature-verified builds). +- Some native addons need musl prebuilds or source compilation. +- A musl Node.js binary only runs on a musl base, so the runtime stage must also + be Alpine (not `debian:bookworm-slim` or distroless). + +Prefer the default Debian image unless you specifically need Alpine. The SSR +pattern with an Alpine runtime: + +```dockerfile [Dockerfile] +# syntax=docker/dockerfile:1 + +FROM ghcr.io/voidzero-dev/vite-plus:latest-alpine AS build +WORKDIR /app +COPY --chown=vp:vp package.json pnpm-lock.yaml .node-version ./ +RUN vp install --frozen-lockfile +COPY --chown=vp:vp . . +RUN vp build +RUN cp "$(vp env which node | head -1)" /tmp/node + +FROM ghcr.io/voidzero-dev/vite-plus:latest-alpine AS deps +WORKDIR /app +COPY --chown=vp:vp package.json pnpm-lock.yaml .node-version ./ +RUN vp install --frozen-lockfile --prod + +# Runtime must be a musl base so the musl Node.js binary runs. +FROM alpine:3.21 AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN apk add --no-cache libstdc++ +COPY --from=build /tmp/node /usr/local/bin/node +COPY --from=build /app/dist ./dist +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/package.json ./ +USER nobody +EXPOSE 3000 +CMD ["node", "dist/server.js"] +``` + +For a static SPA there is no Node.js at runtime, so only the builder changes: +swap the build stage to `ghcr.io/voidzero-dev/vite-plus:latest-alpine`; the +`nginx:alpine` runtime and its output are unchanged. + +## Notes + +- **Node.js version**: the image provisions the version from `.node-version` / + `engines.node` / `devEngines.runtime` at build time. There is no need to pick a + Node-specific image tag. +- **Non-root user**: the image runs as the non-root `vp` user, so copy sources + with `COPY --chown=vp:vp ...` as shown. Without it, `COPY` writes root-owned + files that `vp install` cannot update (permission denied). +- **Native addons**: the image includes a C/C++ build toolchain (`build-essential`, + `python3`), so native dependencies such as `better-sqlite3` compile during + `vp install`. +- **glibc by default**: the default image is glibc based so it uses the official, + signature-verified Node.js builds. An [Alpine/musl variant](#alpine-musl-variant) + is also published (`-alpine` tags) with the tradeoffs noted above. +- **Custom base image**: to add `vp` to your own base image instead, run the + installer: `curl -fsSL https://vite.plus | bash` (set `VP_VERSION` to pin a + version). diff --git a/rfcs/docker-image.md b/rfcs/docker-image.md new file mode 100644 index 0000000000..f136cf6e5b --- /dev/null +++ b/rfcs/docker-image.md @@ -0,0 +1,461 @@ +# RFC: Official Vite+ Docker Image + +- Issue: [#1490](https://github.com/voidzero-dev/vite-plus/issues/1490) +- Plan: [#1324](https://github.com/voidzero-dev/vite-plus/issues/1324) ("Distribute `vp` across Homebrew, Windows installer, Docker image, apt etc.") +- Status: Accepted (implementation in progress) + +## Summary + +Publish an official Vite+ Docker image to GHCR that bundles the `vp` global CLI +for the **build, CI, and development** phases. The image is a toolchain image, +not a production runtime image. Because `vp` already reads `.node-version` / +`engines.node` / `devEngines.runtime` and downloads that exact Node version, the +image needs no Node-version-specific tags: one image builds any project against +its pinned Node. + +For production, this RFC does not ship a runtime image. Instead it documents a +multi-stage pattern where the `vp` builder resolves and downloads the exact +official (glibc, signature-verified) Node, and a slim final stage copies just +that Node binary plus the built app and production dependencies into a small +glibc base (no `vp`). This keeps deployed images small while honoring the +project's pinned Node version, which is what [#1490](https://github.com/voidzero-dev/vite-plus/issues/1490) +asks for. + +## Motivation + +### The problem (#1490) + +When containerizing a Vite+ project, users need the Node version to match the +project's `.node-version` exactly. The reporter's project pins `24.15.0`: + +```text +Environment: + Package manager pnpm v10.33.2 + Node.js v24.15.0 (.node-version) +``` + +Their options today both have downsides: + +- `node:24-alpine` matches the major version and is reasonably small, but it is + musl-based and roughly doubles the image size in their case, and the tag does + not pin the exact patch version. +- `alpine:3.23` + `apk add nodejs` is much smaller, but Alpine currently ships + `24.14.1`, which does not match the pinned `24.15.0`. + +There is no Vite+ Docker image or documented Docker pattern that keeps the +container Node aligned with `.node-version`. This RFC provides both. + +### Why Vite+ is well positioned + +Every comparable tool delegates the Node version to the base `node:*` image tag +and only manages the _package manager_ (via Corepack). Vite+ already manages the +Node runtime itself: it reads the project's config and downloads the exact Node, +verifying the official `SHASUMS256.txt.asc` PGP signature (see +[`js-runtime.md`](./js-runtime.md) and +[`verify-node-shasums-signature.md`](./verify-node-shasums-signature.md)). The +Docker story can build directly on that machinery instead of reinventing +version pinning with image tags. + +## Prior art + +Researched against current official docs (2026-06-25). Summary of how +comparable tools handle Node version + Docker: + +| Tool | Official image | How the Node version is set | Default base | musl/Alpine stance | +| ----------------- | ---------------------------------- | ------------------------------------------------ | --------------------------- | ----------------------------------------- | +| Volta | no (community only) | `volta` field in package.json, auto-fetch | glibc only | unsupported (libc dependency) | +| mise | exists but "do not use" | `mise install` from `.tool-versions`/`mise.toml` | debian-slim | discouraged; needs `MISE_LIBC=musl` | +| proto / moon | no (moon docs only) | layered on top of `node:*` base | `node:latest` | needs `MOON_TOOLCHAIN_FORCE_GLOBALS=true` | +| asdf | no (community only) | `asdf install` from `.tool-versions` | community | per-plugin; glibc Node by default | +| pnpm | yes (`ghcr.io/pnpm/pnpm`, no Node) | base `node:*` tag + Corepack | debian-slim | not addressed | +| Yarn | no | base `node:*` tag + Corepack (`packageManager`) | `node:*` | n/a | +| Turborepo | no | base `node:*` tag; `turbo prune --docker` | `node:*-alpine` | adds `libc6-compat` | +| Nx | no | base `node:*` tag; `prune-lockfile` | `node:lts-alpine` | not addressed | +| Bun | yes (`oven/bun`) | own runtime | debian; offers distroless | not discussed | +| Deno | yes (Hub + GHCR) | own runtime; ships a `:bin` image to copy in | debian; offers distroless | non-root default | +| Node official | yes | the tag is the version | debian (`-slim`, `-alpine`) | warns musl breaks glibc apps | +| distroless nodejs | yes (`gcr.io/distroless/nodejsNN`) | copy artifacts in | debian/glibc, ~45MB | glibc only | + +Key takeaways that shape this RFC: + +1. **No one else manages Node from a config file in a usable published image.** + The version managers (Volta, mise, proto, asdf) either ship no official image + or one flagged unusable, and they all hit the musl wall because managed Node + means official glibc builds. The package-manager and monorepo tools pin Node + only via the base `node:*` tag. Vite+ collapsing both axes (Node + toolchain) + into one deterministic, project-driven build step is a genuine differentiator. + +2. **The closest analog (mise) and the runtimes (Deno) validate the chosen + pattern.** mise's documented best practice is to copy the small static binary + into a slim glibc base and install the pinned tool at build time, not to ship + a fat all-in-one image. Deno ships a `:bin` image precisely so users can + `COPY --from=denoland/deno:bin /deno ...` into any base, and its distroless + variant copies just the binary onto `gcr.io/distroless/cc`. This is exactly + the multi-stage "copy the resolved Node in" pattern below. + +3. **glibc is the consensus default.** Every Node-managing tool warns about or + breaks on musl. Defaulting to glibc keeps official signature-verified Node + (the unofficial musl builds publish no PGP signature) and avoids native-addon + surprises. + +4. **Monorepo pruning is the one capability plain package managers lack.** + Turborepo `turbo prune --docker` and Nx `prune-lockfile` exist only because a + shared lockfile makes one package's change rebuild every container. Vite+ + owns the workspace graph, so a future `vp prune --docker` is a natural + follow-up (see Future Work). + +Sources: pnpm ; Turborepo ; +Nx ; mise +; moon ; +Volta ; Bun ; +Deno ; Node ; +distroless . + +## User scenarios + +The official image is a toolchain image. The scenarios it serves, in priority +order: + +1. **Build stage for app deployment (primary).** Used as `FROM ... AS build` in a + multi-stage Dockerfile. `vp install` + `vp build` produce the app; the exact + Node from `.node-version` is copied into a slim final stage. This is the + #1490 anchor. +2. **Container-native CI (primary).** GitLab CI, Buildkite, CircleCI, Jenkins + agents, Tekton, etc. set `image: ghcr.io/voidzero-dev/vite-plus:` and run + `vp install`, `vp check`, `vp test`, `vp build`. (GitHub Actions users are + already served by `setup-vp`, so this targets the rest of the ecosystem.) +3. **Reproducible dev environments (secondary).** Devcontainers, Codespaces, and + onboarding: a single image pins Node + package managers + vp so the toolchain + matches the repo with zero host setup. +4. **Ad-hoc / evaluation (secondary).** `docker run --rm -v $PWD:/app -w /app +ghcr.io/voidzero-dev/vite-plus vp ` to try vp or reproduce a bug report + on a clean toolchain. +5. **Platform / monorepo builders (secondary).** Internal PaaS and buildpack-style + systems standardizing on a canonical vp builder; monorepo single-app builds + (which motivate the future `vp prune --docker`). + +What it is explicitly **not**: the production runtime image. Shipping the full +toolchain (vite, rolldown, vitest, oxlint, ...) into a deployed container is the +bloat #1490 is complaining about. Production images are produced from the builder +via the documented multi-stage pattern. + +## Goals + +1. Publish a maintained, multi-arch (`linux/amd64`, `linux/arm64`) Vite+ + toolchain image on GHCR. +2. Honor `.node-version` automatically at build time via vp's existing managed + runtime, with no Node-version-specific image tags. +3. Document a recommended multi-stage pattern that produces a small production + image with the exact pinned Node and no vp. +4. Keep official, signature-verified glibc Node end to end (builder downloads it, + runtime copies it). +5. Provide patterns for the secondary scenarios (CI, devcontainer, static SPA, + ad-hoc). + +## Non-Goals + +1. A production runtime image (documented pattern instead, see Future Work for a + possible thin runtime base). +2. Node-version-keyed image tags (the tag sprawl this design avoids). +3. Alpine/musl as the _default_. glibc is the default (official, + signature-verified Node, no native-addon breakage). An Alpine/musl image is + published as an opt-in `-alpine` variant with documented caveats (see Design); + it is not the recommended default. +4. `vp prune --docker` monorepo pruning (Future Work). +5. Docker Hub publishing (GHCR only for now). + +## Design + +### Image role and version-alignment mechanism + +The image bundles `vp` and provisions Node at build time: + +1. In the build stage, `vp install` / `vp build` cause vp to read `.node-version` + (or `engines.node` / `devEngines.runtime`), download that exact official Node, + verify its PGP signature, and cache it under + `$VP_HOME/js_runtime/node//`. +2. The documented multi-stage pattern copies the resolved Node binary plus the + built app and production dependencies into a slim glibc final stage that does + not contain vp. + +This makes one image version-agnostic across every project's pinned Node, +eliminates the Corepack-in-Docker class of problems other tools hit, and keeps +deployed images small. + +### Base image, contents, and variants + +- **Base:** `debian:bookworm-slim` (glibc). Glibc is required so vp downloads the + official signature-verified Node and so native addons behave; debian-slim is + the consensus small glibc base (pnpm's choice) and provides the shell, `apt`, + and `git` that build/CI/dev scenarios need. +- **Preinstalled:** `vp` (on `PATH`), `ca-certificates`, `curl`, `git`, and a + build toolchain (`build-essential`, `python3`, `pkg-config`) for native addon + compilation (for example `better-sqlite3`). Package managers are handled by + vp's managed corepack/runtime, so they are provisioned per-project rather than + baked to a fixed version. +- **No baked default Node.js:** the installer pre-provisions a default Node.js + (~190 MB); the image drops it (`rm -rf $VP_HOME/js_runtime`) because each + project provisions its own pinned Node at build time, so a default is dead + weight in a builder. The `node`/`npm`/`npx` shims remain and fetch the right + version on first use. This keeps the toolchain image ~190 MB smaller, more than + a switch to Alpine/musl would save (and without the musl tradeoffs). +- **User:** create a non-root `vp` user (mirroring Bun's `USER bun` and Deno's + `USER deno`); document switching to root for steps that need `apt`. Because the + image runs as non-root, the documented multi-stage examples copy sources with + `COPY --chown=vp:vp ...`; without it `COPY` writes root-owned files that + `vp install` cannot update (permission denied). Verified end to end against the + published preview image. +- **Alpine/musl variant (opt-in):** an `alpine:3` (musl) toolchain image + (`docker/Dockerfile.alpine`), published under `-alpine` tags. It produces the + smallest runtime (an Alpine SSR runtime measured ~136 MB vs ~150 MB distroless + and ~198 MB debian-slim), but carries the musl tradeoffs: Node comes from the + unofficial, **unsigned** musl builds; native addons may need musl prebuilds or + source compilation; and a musl Node binary only runs on a musl base, so the + runtime stage must also be Alpine. The Debian image stays the recommended + default. Builds via the same matrix as the Debian image. +- **Possible later variant:** a `-slim` toolchain image without the native build + toolchain for projects with no native deps (Future Work). + +### How `vp` gets into the image + +The image installs `vp` with the official install script, pinned to the release +version: + +```dockerfile +RUN curl -fsSL https://vite.plus | VP_VERSION="${VP_VERSION}" bash +``` + +The publish job runs after the npm release, so the pinned version is already on +the registry. This reuses the install script's battle-tested platform detection +(including correct gnu/musl and amd64/arm64 selection under buildx), so the same +Dockerfile produces every architecture without a per-arch artifact copy. The +image version maps 1:1 to a `vp` release via the `VP_VERSION` build arg, and the +same one-liner is the documented way to add `vp` to a custom base image. + +A fully hermetic build that copies the per-arch `vp` binary from the release +artifacts (no network at image-build time) is a possible later hardening; it is +not required for v1. + +### Tagging + +Tags track the `vp` version, not Node: + +- `ghcr.io/voidzero-dev/vite-plus:latest` +- `ghcr.io/voidzero-dev/vite-plus:` (for example `:1`) +- `ghcr.io/voidzero-dev/vite-plus:.` (for example `:1.4`) +- `ghcr.io/voidzero-dev/vite-plus:..` (for example `:1.4.2`) + +The Alpine variant publishes the same set with an `-alpine` suffix +(`:latest-alpine`, `:1-alpine`, `:1.4-alpine`, `:1.4.2-alpine`). + +Users pin by exact tag or digest for reproducibility. No `node-` tags. + +### Security and reproducibility + +- Official, signature-verified glibc Node throughout (no unofficial musl builds). +- Non-root default user. +- Multi-arch manifest (`linux/amd64`, `linux/arm64`); vp already ships + `{x86_64,aarch64}-unknown-linux-gnu` binaries. +- Pinnable by digest. + +### Locating the resolved Node for the runtime stage + +No new CLI surface is required: `vp env which node` prints the resolved Node +binary path as its first (uncolored, pipe-friendly) line, and the runtime lives +at `$VP_HOME/js_runtime/node//bin/node`. The runtime stage copies that +file directly. + +### Publishing pipeline + +Add an image build/publish job to the release flow (`release.yml` / +`reusable-release-build.yml`) that builds the multi-arch image from the release +binaries and pushes to GHCR with the tag set above, gated on a successful +release. (Exact wiring is an implementation detail for the PR.) + +### Pre-release validation (preview image) + +To verify the image before a real release, the `pkg.pr.new` workflow +(`publish-to-pkg.pr.new.yml`) also builds the multi-arch image, but from the PR's +pkg.pr.new build (`VP_PR_VERSION`), and pushes it as `ghcr.io/voidzero-dev/vite-plus:pr-` +(never `latest`). This reuses the exact same `docker/Dockerfile` as the release, +so labeling a PR with `pkg.pr.new` produces a pullable preview image that +exercises the real build path. + +### Docs example verification + +The Dockerfile patterns documented below (and in `docs/guide/docker.md`) are kept +honest by a reproduction repo whose GitHub Actions build and smoke-test each +example end to end (build the image, run the container, assert `HTTP 200`, and +assert the SSR runtime Node.js matches the pinned `.node-version`): + +- + +## Recommended Dockerfile patterns (documented for users) + +### 1. SSR / Node-server app, slim runtime (the #1490 case) + +```dockerfile +# syntax=docker/dockerfile:1 + +# --- build stage: official Vite+ toolchain image --- +FROM ghcr.io/voidzero-dev/vite-plus:1 AS build +WORKDIR /app + +# Dependency layer first for cache reuse. --chown is required: the image runs as +# the non-root vp user, and COPY would otherwise write root-owned files that +# vp install cannot update. +COPY --chown=vp:vp package.json pnpm-lock.yaml .node-version ./ +RUN vp install --frozen-lockfile + +# Build. vp reads .node-version and provisions that exact Node automatically. +COPY --chown=vp:vp . . +RUN vp build + +# Export the exact resolved Node binary for the runtime stage. +RUN cp "$(vp env which node | head -1)" /tmp/node + +# --- deps stage: production-only dependencies (fresh --prod, so devDeps are +# excluded; running --prod over the full install above would not prune them) --- +FROM ghcr.io/voidzero-dev/vite-plus:1 AS deps +WORKDIR /app +COPY --chown=vp:vp package.json pnpm-lock.yaml .node-version ./ +RUN vp install --frozen-lockfile --prod + +# --- runtime stage: small, glibc, no vp --- +FROM debian:bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# Exact Node from .node-version (official, signature-verified glibc build). +COPY --from=build /tmp/node /usr/local/bin/node + +COPY --from=build /app/dist ./dist +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/package.json ./ + +USER nobody +EXPOSE 3000 +CMD ["node", "dist/server.js"] +``` + +The deployed image carries only Node + app + production deps, matches +`.node-version` exactly, and is much smaller than the default `node:*` image. +Production dependencies must be installed in a separate `deps` stage: running +`vp install --prod` over the full install in the build stage does not prune the +already-installed devDependencies (the large `vite-plus` toolchain), so it would +otherwise be copied into the runtime. A distroless final base +(`gcr.io/distroless/cc`) is a documented size/security upgrade for users who do +not need a shell at runtime (see Future Work). + +### 2. Static SPA / SSG + +```dockerfile +FROM ghcr.io/voidzero-dev/vite-plus:1 AS build +WORKDIR /app +COPY --chown=vp:vp package.json pnpm-lock.yaml .node-version ./ +RUN vp install --frozen-lockfile +COPY --chown=vp:vp . . +RUN vp build + +FROM nginx:alpine AS runtime +COPY --from=build /app/dist /usr/share/nginx/html +``` + +No Node at runtime; the vp image is only the builder. + +### 3. Container-native CI + +```yaml +# e.g. GitLab CI +build: + image: ghcr.io/voidzero-dev/vite-plus:1 + script: + - vp install --frozen-lockfile + - vp check + - vp test + - vp build +``` + +### 4. Devcontainer + +```jsonc +// .devcontainer/devcontainer.json +{ + "image": "ghcr.io/voidzero-dev/vite-plus:1", +} +``` + +### 5. Ad-hoc / evaluation + +```bash +docker run --rm -it -v "$PWD:/app" -w /app ghcr.io/voidzero-dev/vite-plus vp build +``` + +## Open questions + +1. **Default app Node in the image.** The toolchain image bakes no specific app + Node (vp downloads the pinned version at build, needing network). Should we + offer a variant with an LTS Node prebaked for faster/offline builds, or rely + on caching and `VP_NODE_DIST_MIRROR`? (Leaning: no prebaked Node by default; + revisit with a prebaked or offline variant if demand appears.) +2. **Native build toolchain by default.** Include `build-essential`/`python3` in + the default image (larger, but native addons just work), or keep the default + lean and add them in a `-full` variant? (Leaning: include by default since + this is a builder image; offer a `-slim` later.) +3. **`vp install --prod` semantics for the runtime copy.** Confirm the exact flag + set vp exposes for a production-only install and whether a dedicated deps stage + improves layer caching in the documented pattern. +4. **Image naming.** `ghcr.io/voidzero-dev/vite-plus` vs a `-toolchain` suffix to + leave room for other images later. + +## Future Work + +1. **`vp prune --docker`** for monorepos: emit a target-scoped subset + (package.json files, pruned lockfile, source) so the dependency-install layer + caches across unrelated workspace edits, matching Turborepo `turbo prune` and + Nx `prune-lockfile`. This is the one capability plain package managers cannot + offer and the main reason monorepo Docker guides cite those tools. Likely its + own RFC. +2. **Distroless runtime guidance/variant.** Document (or provide) a + `gcr.io/distroless/cc` final stage and the `tini` PID-1 pattern for a smaller, + shell-less, better-CVE-posture runtime. +3. **Thin runtime base image.** Reconsider only if the documented copy-Node-in + pattern proves insufficient; would reintroduce Node-version coupling, so not + planned. +4. **Alpine/musl variant (shipped as opt-in).** Published under `-alpine` tags + (`docker/Dockerfile.alpine`); the Debian image stays the default. It serves + teams that mandate Alpine and yields the smallest runtime (an Alpine SSR + runtime measured ~136 MB). It ships with loud caveats because the tradeoffs + are real: + + - On musl, vp downloads Node from `unofficial-builds.nodejs.org`, which + publishes no PGP signature (see `crates/vite_js_runtime/src/providers/node.rs`), + so the Alpine variant does not get the "official, signature-verified Node" + guarantee the Debian image has. + - musl is the classic native-addon sharp edge (prebuilt addons are usually + glibc; on musl they need musl prebuilds or source compilation plus + `gcompat`/`libc6-compat`), which Vite+ projects hit regularly (better-sqlite3, + sharp). The wider field treats musl as a hazard for the same reasons (Volta + unsupported on musl, mise needs `MISE_LIBC=musl`, moon needs + `MOON_TOOLCHAIN_FORCE_GLOBALS=true`, Turborepo `apk add libc6-compat`). + - A musl Node binary only runs on a musl base, so the documented Alpine + multi-stage pattern uses an Alpine runtime stage (not debian-slim/distroless). + + Remaining follow-up: a documented libc autodetect/override and reducing the + ongoing support burden. + +5. **Docker Hub publishing** for discoverability, in addition to GHCR. +6. **Offline / airgapped builds**: a prebaked-Node variant and `VP_NODE_DIST_MIRROR` + guidance. + +## References + +- Issue: [#1490](https://github.com/voidzero-dev/vite-plus/issues/1490) +- Q2 plan: [#1324](https://github.com/voidzero-dev/vite-plus/issues/1324) +- JS runtime management: [`js-runtime.md`](./js-runtime.md) +- Node signature verification: [`verify-node-shasums-signature.md`](./verify-node-shasums-signature.md) +- CI guide: `docs/guide/ci.md` +- Distribution prior art: pnpm , Deno , + mise , Turborepo + , distroless + .