Distroless Node.js Docker images, built from Ubuntu LTS with Canonical's Chisel
100K+
Distroless Node.js Docker images, built from Ubuntu LTS with Canonical's Chisel
nodejs-distroless provides minimal, production-ready Docker images for running Node.js applications. Each image contains the official Node.js binary and only the runtime libraries it needs, cut from Ubuntu LTS with Canonical's Chisel and inspired by Google's distroless approach. There is no shell, no package manager and no unnecessary OS files, which keeps the images small and the attack surface low.
Images are published for linux/amd64 and linux/arm64 to:
nvitaterna/nodejs-distrolessghcr.io/nvitaterna/nodejs-distrolessBoth carry the same tags and the same image digests. If Docker Hub's pull rate limits get in your way, pull from ghcr.io instead.
OS updates for every Node.js version, not just the newest one.
Public distroless Node.js images, such as Google's distroless and Chainguard's free images, only keep the newest release up to date. Pin an exact Node.js version and that image is frozen the moment a newer patch ships: it never gets another OS or library security update.
Sometimes you cannot move to the newest patch right away, because of a regression, a compatibility test cycle or a release freeze. Here, every published version of every supported Node.js line is rebuilt whenever the Ubuntu base changes, so 22.15.0 keeps receiving the same OS fixes as 22.23.4. You choose when to upgrade Node.js, without giving up OS patches in the meantime.
Because of this, exact-version tags are rebuilt and move to new digests over time. Pin by digest if you need a byte-for-byte reproducible image.
| nodejs-distroless | Google distroless | Chainguard (free) | node:*-alpine | node:*-slim | |
|---|---|---|---|---|---|
| Base | Ubuntu LTS (Chisel) | Debian | Wolfi | Alpine | Debian |
| libc | glibc | glibc | glibc | musl | glibc |
| Shell / package manager | No (-debug has BusyBox) | No (:debug has BusyBox) | No (-dev has both) | Yes | Yes |
| Runs as non-root by default | Yes | :nonroot tags | Yes | No | No |
| Node.js versions | every release of every supported line | newest of each supported line | newest only | every release | every release |
| OS updates for older Node.js versions | Yes, every version is rebuilt | No | No | No | No |
| Size, amd64 compressed | 55.0 MB | 60.3 MB | 66.1 MB | 62.9 MB | 85.7 MB |
Sizes are the download size of the Node.js 26 images for linux/amd64 (Chainguard: latest, also 26.x), measured on 2026-11-04 with scripts/image-sizes.sh. Run it to get current numbers or other platforms:
scripts/image-sizes.sh # linux/amd64
scripts/image-sizes.sh --platform linux/arm64
scripts/image-sizes.sh node:22-slim nvitaterna/nodejs-distroless:22 # any images
Alpine uses musl, which some native addons and prebuilt binaries don't support. The Debian-based official images include a shell, package manager and many OS packages that a production container doesn't need, each one something to patch.
node user.linux/amd64 and linux/arm64.Every maintenance, LTS and current Node.js release line is published, tagged by:
| Tag | Example | Points to |
|---|---|---|
| full version | 22.23.4 | that exact release |
| major.minor | 22.23 | newest patch of that minor |
| major | 22 | newest release of that line |
lts | lts | newest release of the newest Active LTS line |
latest | latest | newest release of the newest line, which may be a Current (non-LTS) line |
Every tag also has a debug variant with a -debug suffix, e.g. 22-debug, lts-debug, latest-debug.
For production, lts or a major tag such as 22 is usually the right choice: you get Node.js patches and OS updates without jumping to a new major. The full list of versions and their tags is in versions.json.
Which Node.js lines are published follows the official Node.js release schedule (schedule.json):
26.0.0. Alpha pre-releases are not published.versions.json. Its images and tags (including the major tag, e.g. 20) stay on Docker Hub and GHCR, but are never rebuilt or rescanned again, so they stop receiving OS security updates. lts and latest will already point to a newer line by then.Plan to move off a line before its end-of-life date. The dates for every line are on the Node.js releases page.
These images are for running pre-built applications. Use a multi-stage build: install and build in a standard Node.js image, then copy the result into the distroless image.
# Build stage: the same Node.js version as the runtime, so native addons
# are compiled against the right ABI
FROM node:22.23.4 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# skip this step if your app has no build script
RUN npm run build
RUN npm prune --omit=dev
# Production stage
FROM nvitaterna/nodejs-distroless:22.23.4
WORKDIR /home/node/app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["dist/index.js"]
Replace dist and dist/index.js with your app's build output and entry point.
Add a .dockerignore so COPY . . doesn't send your local node_modules, build output, .git or secrets to the build:
node_modules
dist
.git
.env*
Notes:
node, so CMD takes the arguments you would pass to node.node (uid 1000), with home directory /home/node. There is no root user to switch to, and you do not need a USER instruction.COPY --chown=node:node.NODE_ENV. Set it yourself (as above) if your app or its dependencies rely on it.node:22 and nodejs-distroless:22 both track the newest 22.x but are updated at different times, so pinning the full version keeps them in step.Tags are rebuilt whenever the Ubuntu base changes (see Why?). For a byte-for-byte reproducible build, pin the digest as well as the tag:
docker buildx imagetools inspect nvitaterna/nodejs-distroless:22.23.4 --format '{{.Manifest.Digest}}'
FROM nvitaterna/nodejs-distroless:22.23.4@sha256:<digest>
The tag is kept for readability; the digest decides what is pulled. It is the digest of the multi-arch manifest list, so the same line works on amd64 and arm64, and it is the same on Docker Hub and GHCR. Renovate and Dependabot can update pinned digests for you, so you still pick up base image updates, through reviewed PRs.
The image has no init process, so node runs as PID 1. Linux does not apply the default action for SIGTERM or SIGINT to PID 1, so unless your app handles those signals itself, docker stop waits out its timeout (10 seconds by default) and then kills the container with SIGKILL. PID 1 is also responsible for reaping zombie child processes.
Google's distroless Node.js images behave the same way. Either:
run with an init process: docker run --init or init: true in Docker Compose; or
handle the signals in your app and shut down gracefully:
function shutdown() {
// stop accepting connections; exit once in-flight requests finish
server.close(() => process.exit(0));
// don't let a slow request outlive the grace period and get SIGKILLed mid-write
setTimeout(() => process.exit(1), 8000).unref();
}
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
Keep the timeout below the stop timeout: 10 seconds for docker stop, terminationGracePeriodSeconds (30 by default) in Kubernetes.
Kubernetes has no equivalent of --init, so in a pod, handling the signals in the app is the usual answer. shareProcessNamespace: true makes the pod's pause container PID 1 instead, but it also lets every container in the pod see the others' processes and, through /proc, their filesystems.
Handling the signals yourself is worth doing even with an init process, so in-flight requests can finish before the container exits.
To check, time a stop: it should take well under the timeout.
docker run -d --name app my-image && sleep 2 && time docker stop app
The images have no shell, so docker exec -it <container> sh does not work. Options, from least to most invasive:
Run a one-off script with node. The entrypoint is node, so you can inspect the filesystem or environment without a shell:
docker run --rm nvitaterna/nodejs-distroless:22 -e 'console.log(require("fs").readdirSync("/home/node"))'
docker exec <container> node -e 'console.log(process.env)'
Attach a debugging container that shares the target's namespaces, so you keep the production image unchanged:
# Kubernetes: an ephemeral container in the pod, sharing the app's process namespace
kubectl debug -it <pod> --image=busybox --target=<container> --profile=general
# Docker: a busybox container in the app container's PID and network namespaces
docker run --rm -it --pid=container:<container> --network=container:<container> --cap-add SYS_PTRACE busybox
Through the shared PID namespace, the app's filesystem is visible under /proc/1/root/ (this needs SYS_PTRACE, which --profile=general and --cap-add grant). Docker Desktop's docker debug does the same with one command.
Use the -debug variant. Every tag has a -debug counterpart (22-debug, 22.23.4-debug, latest-debug, ...) that is the same image plus a statically linked BusyBox shell and utilities in /busybox. The entrypoint is still node, so it is a drop-in replacement; override the entrypoint to get a shell:
docker run --rm -it --entrypoint sh nvitaterna/nodejs-distroless:22-debug
docker exec -it <container> sh # in a container started from a -debug image
It runs as the node user like the regular image. If you need root, start it with --user 0. Use the debug variant for troubleshooting only, not in production: a shell is exactly what the regular image leaves out.
Every published image is signed with cosign (keyless, through the publish workflow's GitHub identity, logged in the public Rekor transparency log), and carries an SBOM and SLSA provenance generated by BuildKit.
Check the signature to confirm an image was built by this repository's publish workflow from main:
cosign verify nvitaterna/nodejs-distroless:22 \
--certificate-identity https://github.com/nvitaterna/nodejs-distroless/.github/workflows/publish.yml@refs/heads/main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
The same works for ghcr.io/nvitaterna/nodejs-distroless, and the per-arch images are signed too. To enforce this at deploy time, use an admission controller such as Kyverno or the Sigstore policy-controller with the same identity and issuer.
Read the SBOM (SPDX, every OS package and the Node.js binary) or the build provenance (source commit, build arguments, base images):
docker buildx imagetools inspect nvitaterna/nodejs-distroless:22 --format '{{json .SBOM}}'
docker buildx imagetools inspect nvitaterna/nodejs-distroless:22 --format '{{json .Provenance}}'
Both are keyed by platform. To save one platform's SBOM and scan it without pulling the image:
docker buildx imagetools inspect nvitaterna/nodejs-distroless:22 \
--format '{{json (index .SBOM "linux/amd64").SPDX}}' > sbom.spdx.json
grype sbom:sbom.spdx.json
docker exec into a container or install packages at runtime (see Debugging).npm or npx: all dependencies must be installed at build time and copied in.Everything lives in this repository and runs without manual steps:
| Part | Where | What it does |
|---|---|---|
| Chisel base | Dockerfile (base stage) | Builds Chisel and cuts the runtime rootfs (CA certificates, tzdata, libstdc++, libatomic1) from Ubuntu LTS; adds the node user. |
| Node.js layer | Dockerfile (release stage) | Downloads the official Node.js tarball, verifies it against nodejs.org's SHASUMS256.txt, and copies node onto the base. |
| Debug variant | Dockerfile (debug-tools and debug stages) | Cuts busybox-static with Chisel (so scanners see it in the dpkg status) and layers it over the release image, with its applets in /busybox. |
| Version list | versions.json, kept current by tools/version-info/ | The hourly get-new-versions workflow runs the tool and commits any new versions. |
| Publishing | .github/workflows/publish.yml | On every change to main: builds the base once per arch, builds and smoke-tests each version on it, then pushes annotated manifest lists to Docker Hub and GHCR and signs them with cosign. A base change republishes every version; a version list change publishes only the new ones. The last published commit is recorded on the orphan published branch. |
| Tarball cache | .github/actions/node-dist | Keeps each Node.js tarball on the node-dist GitHub release so builds do not depend on nodejs.org being fast. |
| Vulnerability scans | .github/workflows/pr-build-and-test.yml, .github/workflows/scan-images.yml | Every PR-built image is scanned with Grype and fails on fixable High/Critical findings. The newest image of each line is rescanned daily and the results go to the repository's Security tab. |
| Updates | renovate.json | Ubuntu LTS, Chisel release, apt packages, GitHub Actions and the tool's npm dependencies. Non-major updates merge automatically once checks pass; majors wait for review. |
Pull requests build the oldest and newest version of each major line on both architectures, in both variants, and run test/check.js inside the image.
docker build --build-arg NODE_VERSION=22.23.4 -t nodejs-distroless:22.23.4 .
docker run --rm nodejs-distroless:22.23.4 --version
# the debug variant
docker build --target debug --build-arg NODE_VERSION=22.23.4 -t nodejs-distroless:22.23.4-debug .
The build downloads Node.js from nodejs.org and verifies its checksum. Set NODE_DIST_URL to download the tarball from elsewhere.
The code in this repository (the Dockerfile, workflows and tools) is licensed under the MIT License.
The published images also contain third-party software under its own licenses: Node.js (license at /usr/LICENSE in the image) and Ubuntu packages (copyright files under /usr/share/doc/).
Content type
Image
Digest
sha256:ea74b3f02…
Size
51.6 MB
Last updated
3 days ago
docker pull nvitaterna/nodejs-distroless