Home
Unchained
Engineering Blog

Migrating a Node.js application to Chainguard Images

Adrian Mouat, Staff DevRel Engineer

This blog demonstrates how to build a minimal and secure containerized Node.js application. We'll walk through porting a Node.js application from the Node official image to the Chainguard Node Image (also available on the Docker Hub). We'll review how to work through a few common mistakes and end up with fewer CVEs and a smaller container. Although the focus here is on Node.js, the same principles apply to migrating any applications to Chainguard Images.

Note that this blog is excerpted from a larger tutorial which ports a multi-container example application to use Chainguard Images.

The image we are porting is dnmonster. The dnmonster container hosts an API which returns an identicon based on the input it's given, which we’ll demonstrate below.


-- CODE language-bash --

docker run -d -p 8080:8080 amouat/dnmonster
curl --output ./monster.png localhost:8080/monster/wolfi?size=100

In this example, we give dnmonster the input "wolfi," for which it will produce the following image:


Image of pixelated wolfi image output.

The first thing I had to do was update the dependencies so everything compiled. I then moved the application from the older restify module to the more modern express module. The code at this point can be found on the v1 branch of the identidock-cg GitHub repository.

At this stage we have the following Dockerfile:


FROM node

RUN apt-get update && apt-get install -yy --no-install-recommends \
     libcairo2-dev libjpeg62-turbo-dev libpango1.0-dev libgif-dev \
     librsvg2-dev build-essential g++

#Create non-root user
RUN groupadd -r dnmonster && useradd -r -g dnmonster dnmonster
RUN install -d -o dnmonster -g dnmonster /home/dnmonster

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

COPY package.json /usr/src/app/
RUN npm install
COPY ./src /usr/src/app

RUN chown -R dnmonster:dnmonster /usr/src/app
USER dnmonster

EXPOSE 8080

CMD [ "npm", "start" ]

You can clone and build the image with:

git clone https://github.com/chainguard-dev/identidock-cg.git
cd identidock-cg
git switch v1
cd dnmonster
docker build --pull -t dnmonster .

Building this Dockerfile results in a Dockerfile that (at the time of writing) is 1.22 GB in size and has 114 known vulnerabilities according to Docker Scout.

The first step in moving to Chainguard Images is to try switching the image name in to check if anything breaks. In this case, we’ll begin with the developer variant of the Node image. This means changing the first line of the Dockerfile from:


FROM node

To:


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

Unlike the cgr.dev/chainguard/node:latestimage, the :latest-devversion includes a shell and package manager, which we will need for some of the build steps. In general, it’s better to use the more minimal :latestversion where possible in order to keep the size down and reduce the tooling available to attackers. Often the :latest-devimage can be used as a build step in a multi-stage, with a more minimal image such as :latestused in the final production image.

If you try building this image, you’ll find that it breaks in several places. The image needs to install various libraries so that it can compile the node-canvas dependency, and this looks a bit different in Debian than it does in Wolfi (the OS powering Chainguard Images).

In Wolfi, we first need to switch to the root user to install software and we use apk addinstead of apt-get. We then need to figure out the Wolfi equivalents of the various Debian packages, which may not always have a one-to-one correspondence. There are tools to help here — you can consult our migration guides and use apk tools (like apk search libjpeg), but searching the Wolfi GitHub repository for package names will often provide you with what you’re looking for.

The start of the Dockerfile looks like this after making the changes:


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

USER root
RUN apk update && apk add \
     cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \
     librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev

The next change we need to make is to the RUN groupadd …line. Chainguard Images use BusyBox by default, which means groupadd needs to become addgroup. Rewrite the line so that it looks like this:

RUN addgroup dnmonster && adduser -D -G dnmonster dnmonster
RUN install -d -o dnmonster -g dnmonster /home/dnmonster

Finally, the default entrypoint for the Chainguard Image is /usr/bin/node. If we leave the CMDas it is, it will be interpreted as an argument to node, which isn’t what we want. The Docker official image uses an entrypoint script to interpret commands, but this can’t be done in the cgr.dev/chainguard/node:latestimage due to the lack of a shell and we want the :latest-deventrypoint to match. The easiest fix is to change the CMDcommand to ENTRYPOINTwhich will override the /usr/bin/nodecommand:


ENTRYPOINT [ "npm", "start" ]

Once you’ve made all these changes, you should have a Dockerfile that looks like:

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

USER root
RUN apk update && apk add \
    cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \
    librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev

#Create non-root user
RUN addgroup dnmonster && adduser -D -G dnmonster dnmonster
RUN install -d -o dnmonster -g dnmonster /home/dnmonster

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

COPY package.json /usr/src/app/
RUN npm install
COPY ./src /usr/src/app

RUN chown -R dnmonster:dnmonster /usr/src/app
USER dnmonster

EXPOSE 8080

ENTRYPOINT [ "npm", "start" ]

At this point, we have a version of dnmonster that works and is equivalent to the previous version. We can build this image:


docker build --pull -t dnmonster-cg .
…

If we look at the size and vulnerability count:


$ docker images dnmonster-cg
REPOSITORY     TAG       IMAGE ID       CREATED              SIZE
dnmonster-cg   latest    c50ad3559edc   About a minute ago   932MB

$ docker scout cves dnmonster-cg
    ✓ SBOM of image already cached, 463 packages indexed
    ✓ No vulnerable package detected

## Overview

                    │       Analyzed Image
────────────────────┼──────────────────────────────
  Target            │  dnmonster-cg:latest
    digest          │  c50ad3559edc
    platform        │ linux/arm64
    vulnerabilities │    0C     0H     0M     0L
    size            │ 326 MB
    packages        │ 463

## Packages and Vulnerabilities

  No vulnerable packages detected

So the image is significantly smaller at 932MB, but more importantly we've eliminated all 114 vulnerabilities.

But we can still do more. In particular, although 932MB is significantly smaller than the previous version, it's still a large image. To get the size down, we can use a multi-stage build where the built assets are copied into a minimal production image, which doesn't include build tooling or dependencies required only during development.

Ideally, we would use the cgr.dev/chainguard/node:latestimage for this, but we also need to install the dependencies for node-canvas, which means we need an image with apk tools. Normally, I'd use a latest-devimage for this, but in node's case, the latest-devimage is pretty large due to the inclusion of build tooling such as C compilers that can be required by node modules. Instead, we're going to use the wolfi-baseimage and install nodejsas a package.

To do this replace the Dockerfile with the following:


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

USER root
RUN apk update && apk add \
    cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \
    librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev

#Create non-root user
RUN addgroup dnmonster && adduser -D -G dnmonster dnmonster
RUN install -d -o dnmonster -g dnmonster /home/dnmonster

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

COPY package.json /usr/src/app/
RUN npm install
COPY ./src /usr/src/app

RUN chown -R dnmonster:dnmonster /usr/src/app
USER dnmonster

EXPOSE 8080

ENTRYPOINT [ "npm", "start" ]

FROM cgr.dev/chainguard/wolfi-base

RUN apk update && apk add nodejs \
    cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \
    librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev

WORKDIR /app
COPY --from=build /usr/src/app /app

EXPOSE 8080
ENTRYPOINT [ "node", "server.js" ]

We’ve added an as build statement to the first FROM line and added a second build that starts on the line FROM cgr.dev/chainguard/wolfi-base. The second build installs the required dependencies (including Node.js) before copying the build artifacts from the first image. We also changed the entrypoint to execute Node directly, as the image no longer contains npm.

Build and investigate the image:


❯ docker build --pull -t dnmonster-multi .
…
❯ docker images dnmonster-multi
REPOSITORY        TAG       IMAGE ID       CREATED         SIZE
dnmonster-multi   latest    e339f4ee2274   4 minutes ago   345MB

❯ docker scout cves dnmonster-multi
    ✓ Image stored for indexing
    ✓ Indexed 263 packages
    ✓ No vulnerable package detected

## Overview

                    │       Analyzed Image
────────────────────┼──────────────────────────────
  Target            │  dnmonster-multi:latest
    digest          │  e339f4ee2274
    platform        │ linux/arm64
    vulnerabilities │    0C     0H     0M     0L
    size            │ 122 MB
    packages        │ 263

## Packages and Vulnerabilities

  No vulnerable packages detected

This results in an image that is now only 345MB in size and still has zero CVEs.

We're most of the way now, but there are still a couple of finishing touches to make. The first one is to remove the dnmonster user. The wolfi-base image defines a nonrootuser, so we can make the build a little less complicated by using that user directly. The second one is to add in a process manager. We have node running as the root process (PID 1) in the container, which isn't ideal as it doesn't handle some of the responsibilities that come with running as PID 1, such as forwarding signals to subprocesses. You can see this most clearly when you try to stop the image — it takes several seconds as the process doesn’t respond to the SIGTERM signal sent by Docker and has to be hard killed with SIGKILL. To fix this, we can add tini, a small init for containers.

The tinibinary will run as PID 1, launch npm as a subprocess and take care of PID 1 responsibilities.

The final Dockerfile looks like this:


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

USER root

RUN apk update && apk add \
    tini cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \
    librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

ENV NODE_ENV production
COPY package.json /usr/src/app/
RUN npm install
COPY ./src /usr/src/app

FROM cgr.dev/chainguard/wolfi-base

RUN apk update && apk add tini nodejs \
    cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \
    librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev

WORKDIR /app
COPY --from=build /usr/src/app /app

EXPOSE 8080
ENTRYPOINT ["tini", "--" ]
CMD [ "node", "server.js" ]

This version is also available in the main branch of the repository.

Build it:


docker build --pull -t dnmonster-final .
…

And run it to prove it still works:


docker run -d -p 8080:8080 dnmonster-final
...
curl --output ./monster.png 'localhost:8080/monster/wolfi?size=100'

There are still more tweaks that could be made, but for the purposes of this blog, we've made excellent progress. For more Node.js tips, Bret Fisher has some excellent resources on building Node.js containers in this github repo.

Conclusion

We've taken an old Node.js application that had a relatively large image and ported it over to Chainguard Images. Along the way, we've seen how to deal with common issues around migrating to minimal containers and ended up with an excellent result — a small image with zero CVEs. If you want to get started migrating your own application, check out our migration guides and top tips.

Share

Ready to Lock Down Your Supply Chain?

Talk to our customer obsessed, community-driven team.

Get Started