Sign inSign up

nvitaterna/nodejs-distroless

By nvitaterna

•Updated 3 days ago

Distroless Node.js Docker images, built from Ubuntu LTS with Canonical's Chisel

Image
Languages & frameworks
0

100K+

nvitaterna/nodejs-distroless repository overview

⁠nodejs-distroless

Distroless Node.js Docker images, built from Ubuntu LTS with Canonical's Chisel

Docker Pulls Image size Latest version Publish License


⁠Overview

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:

Both 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.


⁠Why?

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.


⁠Compared to other images

nodejs-distrolessGoogle distroless⁠Chainguard⁠ (free)node:*-alpinenode:*-slim
BaseUbuntu LTS (Chisel)DebianWolfiAlpineDebian
libcglibcglibcglibcmuslglibc
Shell / package managerNo (-debug has BusyBox)No (:debug has BusyBox)No (-dev has both)YesYes
Runs as non-root by defaultYes:nonroot tagsYesNoNo
Node.js versionsevery release of every supported linenewest of each supported linenewest onlyevery releaseevery release
OS updates for older Node.js versionsYes, every version is rebuiltNoNoNoNo
Size, amd64 compressed55.0 MB60.3 MB66.1 MB62.9 MB85.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.


⁠Features

  • Runs only as the non-root node user.
  • Only the Node.js runtime and the libraries it needs: no shell, no package manager.
  • Built from Ubuntu LTS with Chisel; the base is rebuilt and all images republished whenever it changes.
  • New Node.js releases are detected and published automatically, usually within the hour.
  • Multi-arch: linux/amd64 and linux/arm64.

⁠Tags

Every maintenance, LTS and current Node.js release line is published, tagged by:

TagExamplePoints to
full version22.23.4that exact release
major.minor22.23newest patch of that minor
major22newest release of that line
ltsltsnewest release of the newest Active LTS line
latestlatestnewest 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⁠.

⁠Support and end of life

Which Node.js lines are published follows the official Node.js release schedule⁠ (schedule.json⁠):

  • A line is added when its first Current release ships, e.g. 26.0.0. Alpha pre-releases are not published.
  • While a line is supported (Current, Active LTS or Maintenance LTS), every new release is published, and every published version of it is rebuilt whenever the Ubuntu base changes.
  • On its end-of-life date, the line is dropped from 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⁠.


⁠Usage

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:

  • The entrypoint is node, so CMD takes the arguments you would pass to node.
  • The only user is node (uid 1000), with home directory /home/node. There is no root user to switch to, and you do not need a USER instruction.
  • Copied files are owned by root and read-only to the app, which is usually what you want. If the app needs to write to a directory, create it in the build stage and copy it with COPY --chown=node:node.
  • The image does not set NODE_ENV. Set it yourself (as above) if your app or its dependencies rely on it.
  • The image includes CA certificates and timezone data.
  • Use the same Node.js version in both stages. 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.
⁠Pinning by digest

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.

⁠Stopping cleanly (signals and PID 1)

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

⁠Debugging

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.


⁠Verifying images

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

⁠Limitations

  • No shell or package manager: you cannot docker exec into a container or install packages at runtime (see Debugging⁠).
  • No npm or npx: all dependencies must be installed at build time and copied in.
  • Cannot run as root or perform privileged operations.
  • Only maintenance, LTS and current Node.js lines receive updates; see Support and end of life⁠.

⁠How it is built

Everything lives in this repository and runs without manual steps:

PartWhereWhat it does
Chisel baseDockerfile (base stage)Builds Chisel and cuts the runtime rootfs (CA certificates, tzdata, libstdc++, libatomic1) from Ubuntu LTS; adds the node user.
Node.js layerDockerfile (release stage)Downloads the official Node.js tarball, verifies it against nodejs.org's SHASUMS256.txt, and copies node onto the base.
Debug variantDockerfile (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 listversions.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.ymlOn 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-distKeeps 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.ymlEvery 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.
Updatesrenovate.jsonUbuntu 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.

⁠Building locally
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.


⁠References


⁠License

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/).


⁠Getting help and contributing

Tag summary

Content type

Image

Digest

sha256:ea74b3f02…

Size

51.6 MB

Last updated

3 days ago

docker pull nvitaterna/nodejs-distroless