Choosing the best Node.js Docker image
September 30, 2022
0 mins readEditor's note:
Updated to include Chainguard Distroless Image for Node.js.
(August 31, 2023) Updated recommended Node.js version, examples and vulnerability scan results to reflect the up-to-date Node.js LTS releases.
Choosing a Node.js Docker image may seem like a small thing, but image sizes and potential vulnerabilities can have dramatic effects on your CI/CD pipeline and security posture. So, how do you choose the best Node.js Docker image?
It can be easy to miss the potential risks of using FROM node:latest
, or just FROM node
(which is an alias for the former). This is even more true if you’re unaware of the overall security risks and sheer file size they introduce to a CI/CD pipeline.
The following is an example of a Node.js Dockerfile that is typically given as a reference in Node.js Docker image tutorials and blog posts — but this Dockerfile is highly flawed and not recommended:
FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
I have previously outlined and provided a step-by-step guide on 10 best practices to containerize Node.js web applications with Docker, which builds on and improves the example to achieve a production-ready Node.js Docker image.
For this post, we’ll use the contrived example above as the contents of a Dockerfile
in order to find an ideal Node.js Docker image.
Your options for a Node.js Docker image
There are actually quite a few options you could go for when building your Node.js image. They range from the official Node.js Docker image that is maintained by the core Node.js team, to the specific Node.js image tags that you could choose from within that particular Docker base image, and even other options such as building your Node.js application on top of a distroless
image from Google or Chainguard, or a bare-bones scratch
image provided by the Docker team.
Out of all of these options, which Node.js Docker image is ideal for you?
Let’s look at them individually to learn more about the benefits and potential risks.
Author’s note: Throughout this article, I’ll compare a point-in-time Node.js version, which was last released around April 2024 and refers to Node.js 22.1.0.
The default node image
Let's start off with the maintained node
image. It is officially maintained by the Node.js Docker team and contains several Docker base image tags, which map to different underlying distributions (Debian, Ubuntu, or Alpine) and different versions of the Node.js runtime itself. There are also specific version tags to target CPU architectures such as amd64
or arm64x8
(the new Apple M1).
The most common node
image tags for the Debian distribution, such as bullseye
or bookworm
, are themselves based on buildpack-deps
, which are maintained by another team.
What happens when you build your Node.js Docker image based on this default node
image, with just the fastify
npm dependency? We’ll use this example in place of a full application deployment for simplicity
FROM node
WORKDIR /app
RUN npm install fastify
From the same directory, build the image with docker build --no-cache -t mynode . —
I’ve also pulled the node:latest
image in this example to show the size difference. You get the following:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
mynode latest 6978fbd7640d 24 seconds ago 1.13GB
node latest 2fb0552f149e 11 days ago 1.11GB
We didn’t specify the Node.js runtime version, so
node
is an alias tonode:latest
, which right now points to Node.js version 22.1.0.Our Docker image size is 1.13GB.
The
node:latest
base image makes up 1.11GB of that size.
What is the dependency and security vulnerability footprint for this latest Node.js image? We can run a container scan to find out. If you would like to follow along and run the same scans against your builds, you will need to have a free Snyk account and install the Snyk CLI which can be done with a simple `npm install -g snyk` command or by following the instructions in our documentation.
We will run snyk container test mynode --file=Dockerfile --exclude-app-vulns
, which reveals the following:
A total of 413 dependencies — these are any open source library that was detected using the operating system package manager, like
curl/libcurl4
,git/git-man
, orimagemagick/imagemagick-6-common
.A total of 179 security issues, such as Buffer Overflows, Use After Free errors, Out-of-bounds Write, and more, were found inside those dependencies.
Older Node.js images, such as Node.js 20.5.1, were found vulnerable to different security issues such as DNS Rebinding, HTTP Request Smuggling, and Configuration Hijacking.
Do you really need wget
, git
, or curl
to be available in the Node.js image for your application? Overall, it’s not a pretty picture — having hundreds of dependencies and tooling in the Node.js Docker image, with hundreds of vulnerabilities counted towards them, leaves a lot of room for potential attacks.
Node.js Docker Hub options node:buster vs node:bullseye vs node:bookworm
If you browse the available tags on Node.js Docker Hub repository, you’ll find several options for alternative Node.js image tags, including node:buster
, node:bullseye
, and node:bookworm
.
All three of these Docker image tags are based on Debian distribution versions. The buster
image tag maps to Debian 10, which has entered its End of Life date in August 2022 through 2024 — so it's not a great choice. The bullseye
image tag maps to Debian 11, is referred to as Debian’s current "old stable" release and has an estimated end-of-life date of June 2026. Finally, bookworm
is the current “stable” release, and, as of this writing, its end of life is yet to be determined — although, based on historical cadence, it’s probably safe to assume it will be sometime in 2028.
Author's note: Because of this, you’re highly encouraged to move all new and existing Node.js Docker images from node:buster
image tags to node:bullseye
, node:bookworm,
or other suitable alternatives.
Let’s build a new Node.js Docker image based on:
FROM node:bookworm
If you build this Node.js Docker image tag and compare to the above results, you’ll get the exact same size, dependency count, and vulnerabilities found. The reason is that node
, node:latest
, and node:bookworm
all point to the same Node.js image tag being built.
Node.js image tag for slimmer images
The official Node.js Docker team also maintains an image tag that explicitly targets the tooling needed for a functional Node.js environment and nothing else.
These Node.js image tags are referred to by the slim
image tag variant, such as node:bookworm-slim
, or Node.js version specific such as node:20-slim
.
Let’s build a Node.js slim
image based on Debian’s current stable release bookworm
:
FROM node:bookworm-slim
The image size already decreased dramatically — from close to a gigabyte of container image to an image size of 231MB. Scanning its content also shows a great decline in overall software footprint, with 89=8 dependencies and only 37 vulnerabilities.
The node:bookworm-slim
is already a better starting point in terms of container image size and security posture.
An LTS Node.js Docker image
So far, our Node.js Docker images were based off of the current version of Node.js, which is Node.js 22. But according to the Node.js releases schedule, this version doesn’t enter its official Active LTS
status until October 2024.
What if we always relied on the long-term support (LTS) versions in the Node.js Docker images we’re building? Let’s update the Docker image tag accordingly and build a new Node.js image:
FROM node:lts-bookworm-slim
The slimmer Node.js LTS version (20.13.1) brings a similar number of dependencies and security vulnerabilities on the image, resulting in a slightly smaller image size of 219MB.
So, as it turns out, while you may have specific requirements to choose between LTS
and Current
Node.js runtime versions, none of them significantly impacts the software footprint of the Node.js image.
Is node:alpine a better choice for a Node.js image?
The Node.js Docker team maintains a node:alpine
image tag and variants of it to match specific versions of the Alpine Linux distributions with those of the Node.js runtime.
The Alpine Linux project is often cited for its incredibly small image size, which is great because it means a smaller software footprint, and by reference, smaller vulnerabilities surface.
FROM node:alpine
...
This will yield a Docker image size of 167MB, which shaves off 64MB from the slim
Node.js images, and in the Alpine image tag — as of the day I’m writing this — only 17 operating system dependencies and one security vulnerability was detected. This may indicate that the alpine
image tag is a good choice for a small image size and vulnerability count altogether.
Is node:alpine
the better choice for a Node.js Docker image?
The Alpine for Node.js image variant might provide an overall small image size and even smaller vulnerabilities count. However, it’s important to recognize that the Alpine project uses musl as the implementation for the C standard library, whereas Debian’s Node.js image tags such as bullseye
or slim
rely on the glibc
implementation. These differences can account for performance issues, functional bugs, or potential application crashes due to the underlying C library differences. Itamar Turner-Trauring also wrote about their experience of unexpected runtime issues relating to Alpine image tags for Python Docker images.
Choosing a Node.js alpine
image tag means you are effectively choosing an unofficial Node.js runtime. The Node.js Docker team doesn’t officially support container image builds based on Alpine. As such, it states that Alpine-based image tags are experimental, may not be consistent, and makes them available from the following unofficial builds. To quote the Unofficial Builds image tags repository:
Unofficial-builds attempts to provide basic Node.js binaries for some platforms that are either not supported or only partially supported by Node.js. This project does not provide any guarantees and its results are not rigorously tested. Builds made available at nodejs.org have very high quality standards for code quality, support on the relevant platforms and for timing and methods of delivery. Builds made available by unofficial-builds have minimal or no testing; the platforms may have no inclusion in the official Node.js test infrastructure. These builds are made available for the convenience of their user community but those communities are expected to assist in their maintenance.
Some notable observations with Node.js alpine
image tag compatibility are:
Yarn being incompatible (issue #1716).
If you require
node-gyp
for cross-compilation of native C bindings, then Python, which is a dependency of that process, isn’t available in the Alpine image and you will have to sort it out yourself (issue #1706).
Distroless Docker Images for Node.js
The last comparison items for our benchmark are Distroless images. There are two main options here: the orginal Google’s distroless container images and the newer Chainguard distroless container images.
What is a distroless docker image?
These images are even slimmer than the slim
Node.js image tag because they only target the application and its runtime dependencies. And so, a distroless Docker image has no package manager, shell, or other general-purpose tooling dependencies, giving them a small size and vulnerability footprint.
Google Distroless image
The Google Distroless project maintains a runtime-specific distroless Docker image for Node.js, identified as gcr.io/distroless/nodejs22-debian12
by its complete namespace and available in Google’s container registry (that’s the gcr.io
part).
Note: Older combinations of Debian and Node.js also exist, but be careful to choose an actively maintained version. For the latest supported images, see the table in the Distroless GitHub repo.
Because the Distroless container images have no software, we can use a Docker multistage workflow to install dependencies for our container and copy them to the distroless images:
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY . /app
RUN npm install
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=build /app /usr/src/app
WORKDIR /usr/src/app
CMD ["server.js"]
Building this distroless Docker image results in a 177MB file, which is a reduction in file size from both the slim
and alpine
image tag variants.
If you’re considering using distroless Docker images, there are some Important considerations to make:
They are based on current stable Debian release versions, meaning they’re up to date with a far out end of life expiration date, which is a great thing.
Because they are Debian-based, they rely on the glibc implementation and are less likely to surprise you with issues in production.
You will soon enough find out that the Distroless team doesn’t maintain fine-grained Node.js runtime versions. This means you need to rely on the general purpose
latest
tag that will be frequently updated, or install based on the SHA256 hash of the image at a certain time.
Chainguard Distroless image
The other option for distroless images is Chainguard. Chainguard supplies multiple images, including ones for older versions of Node and FIPS-enabled versions. They have two images available on the free developer tier "latest," which runs Node 22.1.0 at the time of writing, and a "latest-dev" image, which extends the latest image with a package manager and shell for development and debugging purposes.
We can use a very similar example to the previous Google distroless one:
1FROM cgr.dev/chainguard/node:latest-dev AS build
2WORKDIR /app
3COPY . /app
4USER root
5RUN npm install
6
7FROM cgr.dev/chainguard/node:latest
8COPY --from=build /app /usr/src/app
9WORKDIR /usr/src/app
10CMD ["server.js"]
This results in an 142MB image, similar to the Google Distroless image. There are zero vulnerabilities reported.
It's worth mentioning that all Chainguard Images are built on top of Chainguard's Wolfi Linux distribution. Wolfi is compiled against glibc, so there are no issues with musl compatibility.
Comparison of Node.js Docker image tags
We can refer to the following table to summarize our comparison across different Node.js Docker image tags as of May 15, 2024, when last updated:
Image tag | Node.js runtime version | OS dependencies | OS security vulnerabilities | High and Critical vulnerabilities | Medium vulnerabilities | Low vulnerabilities | Node.js runtime vulnerabilities | Image size | Yarn available |
---|---|---|---|---|---|---|---|---|---|
node:latest | 22.1.0 | 413 | 179 | 3 | 0 | 176 | 0 | 1135MB | Yes |
node:bookworm | 22.1.0 | 413 | 179 | 3 | 0 | 176 | 0 | 1135MB | Yes |
node:bookworm-slim | 22.1.0 | 88 | 37 | 2 | 0 | 35 | 0 | 233MB | Yes |
node:lts-bookworm-slim | 20.13.1 | 88 | 37 | 2 | 0 | 35 | 0 | 219MB | Yes |
node:alpine | 22.1.0 | 17 | 1 | 0 | 0 | 1 | 0 | 145MB | Yes |
22.1.0 | 8 | 16 | 0 | 0 | 16 | 0 | 186MB | No | |
cgr.dev/chainguard/node:latest | 22.1.0 | 25 | 0 | 0 | 0 | 0 | 0 | 134MB | No |
cgr.dev/chainguard/node:latest-dev | 22.1.0 | 66 | 0 | 0 | 0 | 0 | 0 | 651MB | Yes |
Let’s run through the data and insights we learned through each of the different Node.js image tags and decide which is the most ideal.
Development-parity
If your choice of which Node.js image tag to use comes down to dev-consistency — meaning that you want to optimize for the exact same environment parity of development and production — then this may already be a lost battle. In most cases, all 3 major operating systems use a different C library implementation. Linux relies on glibc, Alpine relies on musl, and macOS has its own BSD libc implementation.
Docker image size
Sometimes, size matters. More accurately though, the goal isn’t having the smallest of size but the smallest of software footprint overall. In that case, the slim
image tags aren’t much different in size compared with their alpine
counterparts, and they’re all averaging about 211MB for a container image. Granted, the software footprint of slim
images is still quite high (89 vs alpine
’s 17), and as such, it accounts for a bigger vulnerabilities surface (28 on slim
vs 0
for alpine
).
Security vulnerabilities
Vulnerabilities are an important concern, and have been the center of many articles on why you should be reducing the size of your container images. However, the semantics of security issues matter a lot.
Leaving out the node
and node:bullseye
images due to the larger software footprint and increased security vulnerabilities, we can focus on the smaller set of image types. Comparing between slim
, alpine
, and distroless
, the variation between high and critical security vulnerabilities isn’t high in absolute numbers and ranges between 0 and 2 — a manageable risk that could potentially be irrelevant to your application use-case.
Support and resilience
Ensuring that the Node.js Docker team is able to prioritize and address concerns with regard to your container image builds, and that issues are resolved in a timely manner, goes a long way. With anything that isn’t the official, Debian-based image tags, you are essentially unable to cross this off your checklist.
When using the node
or node:22.1.0-bookworm-slim
image tags, whether you’re opting for a full operating system image or using the slimmer version of dependencies, you are still getting the latest version of the Node.js runtime. While it is an even number (Node.js 22.1.0), at the time of this writing, it still hasn’t been included in the Long Term Support lifecycle, which means it will be bundling new versions of other dependent components such as the latest versions of npm
itself (which has been known for buggy new behavior and requiring time to stabilize).
The bottom line?
The most ideal Node.js Docker image would be a slimmed-down version of the operating system, based on a modern Debian OS, with a stable and active Long Term Support version of Node.js.
This comes down to choosing the node:lts-bookworm-slim
Node.js image tag. I’m in favor of using deterministic image tags, so the slight change I’d make is to use the actual underlying version number instead of the lts
alias.
The most ideal Node.js Docker image tag is node:20.13.1-bookworm-slim
.
If you work within a mature DevOps team that can support custom base images, my second-best recommendation would be Google’s distroless image tag, because it maintains glibc compatibility for official Node.js runtime versions. This workflow will require some maintenance though, so I’d only advise it if you can support that.
Container security for DevSecOps
Find and fix container vulnerabilities for free with Snyk.