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
Example for bash and node version 24:
docker build \
--build-arg BUILD_DATE=`date -u +"%Y-%m-%dT%H:%M:%SZ"` \
--build-arg PNPM_VERSION=12 \
--build-arg VCS_REF=`git rev-parse --short HEAD` \
-t panascais/ci-node:24 \
./24Example for fish and node version 24:
docker build \
--build-arg BUILD_DATE=(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--build-arg PNPM_VERSION=12 \
--build-arg VCS_REF=(git rev-parse --short HEAD) \
-t panascais/ci-node:24 \
./24BuildKit 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. build.ts fetches both and passes --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.