CI-Node is a docker image intended to be used in continuous integration services such as GitLab CI, GitHub Actions, Semaphore CI and Circle CI
Every numeric directory is a line named after its node major version. configuration/ pins the published panascais/node image digest, node release and pnpm major per line, the Docker Hub alpine digest, and the latest and lts aliases.
Smoke test, build and load node 24 for the local platform:
go run ./scripts build 24Locally the build starts from ghcr.io/panascais-docker/node/node:<release> in the local image store, so run go run ./scripts build 24 in a checkout of panascais-docker/node first to test both images together. ./scripts/build-all-parallel.sh [line...] builds every line at once in either repository and logs each build to $TMPDIR/<repository>-builds.
Refresh the pins from the node repository (or a local checkout of it):
go run ./scripts update
go run ./scripts update --node ../node/configurationOn GitHub Actions, the node repository starts the update workflow after every publish. plan compares each line's fingerprint with the annotation on the published image, build smoke tests and pushes the stale lines by digest per architecture and publish tags them as multi-platform images.
Every job pulls Docker Hub images, BuildKit included, through mirror.gcr.io to stay clear of Docker Hub rate limits, and falls back to Docker Hub only when the mirror has not cached an image yet.
BuildKit cache mounts speed up rebuilds by reusing downloaded packages between builds. Both apk and pnpm use them in this image.
The large apk install layer mounts /apk/cache so .apk files are reused across builds. Cache IDs include ${TARGETARCH} so amd64 and arm64 packages do not mix during multi-platform builds. sharing=locked avoids races when matrix jobs build in parallel.
RUN --mount=type=cache,id=ci-node-apk-${TARGETARCH},sharing=locked,target=/apk/cache \
apk update --cache-dir /apk/cache \
&& apk add --cache-dir /apk/cache --cache-predownload \
...Alpine 3.24 ships apk-tools 3.x, where --update / -U means --cache-max-age 0 (always refetch). Do not use it with cache mounts. Pass 1 writes into /apk/cache; when the apk layer re-runs, --cache-predownload reuses cached .apk files. The mount never lands in the final image.
Node patch tags and the pnpm major come from the base image repository: configuration/versions.json and configuration/pnpm.json. go run ./scripts update pins them in this repository together with the published image digest, and the build passes --build-arg NODE_IMAGE=<image@digest> (the base image in FROM) and --build-arg PNPM_VERSION=<upstream major>. ci-node does not keep a local pnpm config copy, and it does not reinstall pnpm@N in the global package list — every supported base already ships that executable.
pnpm 10+ (Node 18+): global binaries live under $PNPM_HOME/bin. Every Node 18+ image inherits PNPM_HOME=/root/.local/share/pnpm, /root/.local/share/pnpm/bin on PATH, and enableGlobalVirtualStore: false (/root/.config/pnpm/config.yaml) from the authoritative base image. pnpm 10 ignores globalBinDir in that yaml file and falls back to PNPM_HOME, which is not on PATH (pnpm#11205). pnpm config set global-bin-dir /root/.local/share/pnpm/bin writes the rc file those versions read, then install with pnpm add -g. Cache-mount the store at /root/.local/share/pnpm/store. Without enableGlobalVirtualStore: false, a store cache mount can break global CLIs at runtime (ae38d12).
pnpm 8 and below (Node 17 and older): single-pass pnpm i -g with the store cache-mounted at pnpm’s default path for that image (no --store-dir override). Measure with the same ENV as the Dockerfile (pnpm store path).
Cache IDs are ci-node-pnpm-${PNPM_VERSION}-${TARGETARCH} so the pnpm major is not checked in.
| pnpm | Node | PNPM_HOME in ci-node |
pnpm store path |
Cache mount target= |
Cache id |
|---|---|---|---|---|---|
| 6 | 12 | (none; bins in /usr/local/bin) |
/root/.pnpm-store/v3 |
/root/.pnpm-store |
ci-node-pnpm-${PNPM_VERSION}-${TARGETARCH} |
| 7–8 | 14–17 | .../pnpm/bin |
.../pnpm/bin/store/v3 |
/root/.local/share/pnpm/bin/store |
ci-node-pnpm-${PNPM_VERSION}-${TARGETARCH} |
| 10+ | 18+ | /root/.local/share/pnpm |
.../pnpm/store/v10 |
/root/.local/share/pnpm/store |
ci-node-pnpm-${PNPM_VERSION}-${TARGETARCH} |
# Node 12 (pnpm 6; store at ~/.pnpm-store, global bins in /usr/local/bin)
RUN --mount=type=cache,id=ci-node-pnpm-${PNPM_VERSION}-${TARGETARCH},sharing=locked,target=/root/.pnpm-store \
packages=" ... " \
&& pnpm i -g $packages
# Node 14–17 (pnpm 7–8)
RUN --mount=type=cache,id=ci-node-pnpm-${PNPM_VERSION}-${TARGETARCH},sharing=locked,target=/root/.local/share/pnpm/bin/store \
packages=" ... " \
&& mkdir -p /root/.local/share/pnpm/bin \
&& pnpm i -g $packages
# Node 18+ (pnpm 10+; inherit PNPM_HOME/bin PATH and enableGlobalVirtualStore: false from the base node image)
RUN --mount=type=cache,id=ci-node-pnpm-${PNPM_VERSION}-${TARGETARCH},sharing=locked,target=/root/.local/share/pnpm/store \
packages=" ... " \
buildable=" --allow-build=... " \
&& mkdir -p /root/.local/share/pnpm/bin \
&& pnpm config set global-bin-dir /root/.local/share/pnpm/bin \
&& pnpm add -g $packages $buildableMount the parent directory (pnpm creates v3 / v10 subdirs inside). ${TARGETARCH} and sharing=locked avoid cross-arch mixing and parallel-build store corruption. Layout commands stay explicit per known pnpm major; a future major is not inferred from the number alone.
When building application images on top of ci-node, use a separate project store (pnpm Docker docs):
ENV PNPM_HOME=/pnpm
ENV PATH=${PNPM_HOME}:${PATH}
RUN --mount=type=cache,id=pnpm-${TARGETARCH},sharing=locked,target=/pnpm/store \
pnpm install --frozen-lockfilepnpm itself comes from /usr/local/bin/pnpm on the base node image.
- Silas Rech (silas@panascais.net)
- Maximilian Schagginger (max@panascais.net)
Interested in contributing to CI-Node? Contributions are welcome, and are accepted via pull requests. Please review these guidelines before submitting any pull requests.