# Migrate a Node.js application to Chainguard Containers

URL: https://chainguard-docs-preview-git-fix-ai-metadata-year.chainguard.app/chainguard/containers/migration/migration-guides/node.md
Last Modified: September 28, 2026
Tags: Chainguard Containers, Migration

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


Chainguard's Node.js container images offer a streamlined migration path for applications seeking enhanced security posture through minimal, distroless design. Built on [Wolfi](/open-source/wolfi/), they include only the packages your application needs, which keeps both the attack surface and the image size small. Daily automated builds ensure your applications always have the latest security patches without manual intervention. For current CVE data on each image and tag, see the [Node entry in the Chainguard Containers directory](https://images.chainguard.dev/directory/image/node/overview?utm_source=cg-academy&utm_medium=referral&utm_campaign=dev-enablement&utm_content=edu-content-chainguard-migration-migrating-node).

**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](https://github.com/wolfi-dev/) 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.




**What are multi-stage builds?**


<p><a href="https://docs.docker.com/build/building/multi-stage/">Multi-stage builds</a> are a Docker feature that allow you to use multiple <code>FROM</code> statements in a single Dockerfile, where each statement begins a new build stage. In a typical pattern, an early stage uses a full-featured builder image to compile code or generate artifacts, while a later stage uses a minimal runtime image and copies in only what's needed to run the application. Only what you explicitly copy from one stage carries forward — everything else is discarded when that stage completes.</p>

<p>This approach has significant security benefits. Build tools like compilers, shells, and package managers are broadly exploitable general-purpose utilities that expand an image's attack surface. By leaving them behind in the builder stage, the runtime image has fewer packages, fewer potential CVEs, and a smaller blast radius in the event of a compromise. Reducing unnecessary components also improves observability and makes risk assessment easier, since every package in the final image can be directly tied to a runtime requirement.</p>

<p>Chainguard's container images are designed with this pattern in mind. Most have a <a href="/chainguard/containers/concepts/container-variants/"><code>:latest-dev</code> development variant</a> suited for use as a builder stage, and a corresponding <code>:latest</code> (or <code>-slim</code>) standard image for the distroless runtime. For example, a Go application can be compiled in the <code>go:latest-dev</code> builder stage and its binary copied into a <code>static</code> or <code>glibc-dynamic</code> runtime image — with no Go toolchain in the final container.</p>




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](https://images.chainguard.dev/directory/image/node/overview?utm_source=cg-academy&utm_medium=referral&utm_campaign=dev-enablement&utm_content=edu-content-chainguard-migration-migrating-node) 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](/chainguard/containers/concepts/getting-started-distroless/).

## 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](/chainguard/containers/migration/migrating-to-chainguard-images/)
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:

```shell
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](/chainguard/containers/migration/migrating-to-chainguard-images/#searching-for-packages)
section of our base migration guide.

## Differences from the official Docker image

If you are migrating from the [official Docker image](https://hub.docker.com/_/node) 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](https://github.com/Yelp/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](https://github.com/chainguard-dev/cg-images-node-migration).

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

```Docker
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:

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

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

```Docker
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:

```Docker
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:

```shell
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](/chainguard/containers/concepts/container-variants/#slim-container-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:

```Docker
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

- The [Node.js image documentation](https://images.chainguard.dev/directory/image/node/overview?utm_source=cg-academy&utm_medium=referral&utm_campaign=dev-enablement&utm_content=edu-content-chainguard-migration-migrating-node)
contains full details on our images, including usage documentation, provenance and security
advisories.

- The [How to Port a Sample Application to Chainguard
Containers](/chainguard/containers/migration/porting-apps-to-chainguard/) article contains an example of porting a
Node.js Dockerfile for a legacy application.

- The [How to migrate a Node.js application to Chainguard Containers](https://www.youtube.com/watch?v=hfpVS-UP4Yw) video works through an example of porting a Node.js Dockerfile.

- Bret Fisher has an excellent [guide to creating Node.js container
images](https://github.com/BretFisher/nodejs-rocks-in-docker/), including advice for using
distroless.

- The [Debugging distroless](/chainguard/containers/troubleshooting/debugging-distroless-images/) guide contains important information for debugging issues with distroless images. You can also refer to the [Verifying containers](/chainguard/containers/security-and-compliance/verifying-chainguard-images-and-metadata-signatures-with-cosign/) resource for details around provenance, SBOMs, and image signatures.

