Migrating to Node.js Chainguard Containers

Learn how to migrate Node.js applications to Chainguard Containers for reduced vulnerabilities, smaller image sizes, and automated security patching
  7 min read

Chainguard’s Node.js containers offer a streamlined migration path for applications seeking enhanced security posture through minimal, distroless design. Built on Wolfi, these containers significantly reduce attack surface compared to traditional Node.js images, resulting in fewer vulnerabilities and smaller image sizes. Daily automated builds ensure your applications always have the latest security patches without manual intervention.

What is Distroless? Distroless container images are minimal container images containing only essential software required to build or execute an application. That means no package manager, no shell, and no bloat from software that only makes sense on bare metal servers.
What is Wolfi OS? Wolfi is a community Linux undistro created specifically for containers. This brings distroless to a new level, including additional features targeted at securing the software supply chain of your application environment: comprehensive SBOMs, signatures, daily updates, and timely CVE fixes.

This article is intended as a guide to porting existing Dockerfiles for Node.js applications to a Chainguard Containers base.

Node.js Chainguard Containers

The Node.js images come in two main flavors; runtime images intended for production usage and builder images intended for use in the build-step of multi-stage builds. The builder images are distinguished by the -dev suffix (e.g., latest-dev).

The production images are intentionally as minimal as possible. They have enough dependencies to run a Node.js application but no more. There is no package manager or shell, which reduces the attack surface of the image, but can also make it difficult to extend the image. The builder images have more build tooling as well as a shell and package manager, allowing them to be easily extended. We still aim to keep CVE counts as low as possible in the builder images and they can be used in production if necessary (such as when the application requires extra system dependencies at runtime), but we recommend using the builder image as the first step in a multi-stage build with the production image as the base for the final image.

This extremely minimal approach to the runtime image is sometimes known as “distroless”. For a deeper exploration of distroless images and their differences from standard base images, refer to the guide on Getting Started with Distroless images.

Migrating From Other Distributions

Dockerfiles will often contain commands specific to the Linux Distribution they are based on. Most commonly this will be package installation instructions (e.g. apt vs yum vs apk) but also differences in default shell (e.g. bash vs ash) and default utilities (e.g. groupadd vs addgroup). Our high-level guide on Migrating to Chainguard Containers contains details about distro-based migration and package compatibility when migrating from Debian, Alpine, Ubuntu and Red Hat UBI base images.

Installing Further Dependencies

Sometimes your applications will require further dependencies, either at build-time, runtime or both. Wolfi has large number of software packages available, so you are likely to be able to install common packages via apk add, but be aware that packages may be named differently than in other distributions.

The easiest way to search for packages is via apk tools. For example:

docker run -it --rm cgr.dev/chainguard/wolfi-base
f273a9aa3242:/# apk update
fetch https://packages.wolfi.dev/os/aarch64/APKINDEX.tar.gz
 [https://packages.wolfi.dev/os]
OK: 53914 distinct packages available
f273a9aa3242:/# apk search cairo
cairo-1.18.0-r1
cairo-dev-1.18.0-r1
cairo-gobject-1.18.0-r1
cairo-static-1.18.0-r1
cairo-tools-1.18.0-r1
harfbuzz-8.4.0-r1
harfbuzz-dev-8.4.0-r1
pango-1.52.2-r1
pango-dev-1.52.2-r1
py3-cairo-1.26.0-r0
py3-cairo-dev-1.26.0-r0

These packages can then be easily added to your Dockerfile. For more searching tips, check the Searching for Packages section of our base migration guide.

Differences from the official Docker image

If you are migrating from the official Docker image there are a few differences that are important to be aware of.

  • Our images run as the node user with UID 65532 by default. If you need elevated privileges for a task, such as installing a dependency, you will need to change to the root user. For example add a USER root statement into a Dockerfile. For security reasons you should make sure that the production application runs with a lower privilege user such as node.
  • WORKDIR is set to /app which is owned by the node user.
  • The Docker Official images have a “smart” entrypoint that interprets the CMD setting. So docker run -it node will launch the Node.js interpreter but docker run -it node /bin/sh will launch a shell. The latter does not work with Chainguard Containers. In the non -dev images, there is no shell to launch, and in the -dev images you will need to change the entrypoint e.g. docker run --entrypoint /bin/sh -it cgr.dev/chainguard/node.
  • The image has a defined NODE_PORT=3000 environment variable which can be used by applications
  • Our Node.js images include dumb-init which can be used to wrap the Node process in order to handle signals properly and allow for graceful shutdown. You can use dumb-init by setting an entrypoint such as: ENTRYPOINT ["/usr/bin/dumb-init", "--"]
  • In general there are many fewer libraries and utilities in the Chainguard Container. You may find that your application has an unexpected dependency which needs to be added into the Chainguard Container.

Migration example

This section has a short example of migrating a Node.js application with a Dockerfile building on node:latest to use the Chainguard Node.js Containers. The code for this example can be found on GitHub.

Our starting Dockerfile uses the node:latest image from Docker Hub in a single-stage build:

FROM node:latest

ENV NODE_ENV production

WORKDIR /usr/src/app

COPY package.json .
RUN npm install
USER node
COPY . .

EXPOSE 3000

CMD npm start

If you’ve cloned the GitHub repository, you can build this image with:

docker build -t node-classic-image -f Dockerfile-classic .

Directly porting to Chainguard Containers with the least number of changes results in this Dockerfile:

FROM cgr.dev/chainguard/node:latest-dev

ENV NODE_ENV production

WORKDIR /usr/src/app

COPY package.json .
RUN npm install
USER node
COPY . .

EXPOSE 3000

ENTRYPOINT npm start

Here we’ve changed the image to cgr.dev/chainguard/latest-dev and the CMD command became ENTRYPOINT.

We can still do better in terms of size and security. A multi-stage Dockerfile would look like:

FROM cgr.dev/chainguard/node:latest-dev AS builder

ENV NODE_ENV production

WORKDIR /usr/src/app

COPY package.json .
RUN npm install
USER node
COPY . .

FROM cgr.dev/chainguard/node:latest

COPY --from=builder --chown=node:node /usr/src/app /app
EXPOSE 3000
ENV NODE_ENV=production
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "app.js"]

If you’ve cloned the GitHub repository, you can build this image with:

docker build -t node-multi-image -f Dockerfile-multi .

The advantages of this build are:

  • we are using dumb-init so the container shuts down cleanly in response to docker stop.
  • we do not have all the build tooling in the final image, resulting in a smaller and more secure production image

Note that in a production app you may want to use a package-lock.json file and the npm ci command instead of npm install to ensure the correct version of all dependencies is used.

Using slim images

If Chainguard’s Node.js image has been added to your organization’s Chainguard Registry, you will have access to more tags than just latest, including slim tags. These represent Chainguard’s slim variants, which have an even smaller attack surface than our standard container images. In the case of Node.js, the slim variants omit some packages that are included in the standard image for compatibility purposes, including npm and busybox.

Because they lack these compatibility packages, the slim Node.js images are often used in multi-stage builds. The following example updates the Dockerfile-multi file shown previously to point to one of Chainguard’s slim Node.js images:

FROM cgr.dev/chainguard/node:latest-dev AS builder

ENV NODE_ENV production

WORKDIR /usr/src/app

COPY package.json .
RUN npm install
USER node
COPY . .

FROM cgr.dev/$ORGANIZATION/node:25-slim

COPY --from=builder --chown=node:node /usr/src/app /app
EXPOSE 3000
ENV NODE_ENV=production
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "app.js"]

This example specifies the :25-slim tag, but be aware that Chainguard includes slim variants for every version of Node.js it offers.

To build an image with a Dockerfile like this, your organization would need to have Chainguard’s Node.js image included in its registry. You would also need to update $ORGANIZATION to reflect the name of your organization’s registry.

Additional Resources

Last updated: 2025-07-23 16:52