Migrating a Node.js application to Chainguard Images
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:
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:latest
image, the :latest-dev
version 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 :latest
version where possible in order to keep the size down and reduce the tooling available to attackers. Often the :latest-dev
image can be used as a build step in a multi-stage, with a more minimal image such as :latest
used 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 add
instead 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 CMD
as 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:latest
image due to the lack of a shell and we want the :latest-dev
entrypoint to match. The easiest fix is to change the CMD
command to ENTRYPOINT
which will override the /usr/bin/node
command:
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:latest
image 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-dev
image for this, but in node's case, the latest-dev
image 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-base
image and install nodejs
as 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 nonroot
user, 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 tini
binary 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.
Ready to Lock Down Your Supply Chain?
Talk to our customer obsessed, community-driven team.