Building a container image for your Node.js application might sound like a trivial task, but there are some pitfalls you’ll want to avoid along the path to containerizing your application. It is all too easy to accidentally include sensitive files, run more than one process in the container, run as root, or ship bloated container images. Because all of these mistakes reflect an image constructed without due care, reducing bloat reduces them all.
This post focuses on the “bloated container images” mistake that’s pretty easy to make when building Node.js application container images. Keep reading to learn about container image layers, how you can slim down a Node.js container image based on Red Hat’s Universal Base Images by over 70% (see Figure 1), and even more best practices for building containers.
Note: All of the examples and code used in this post can be found in this repository on GitHub. You’ll need Node.js 18 and either Docker or Podman installed to follow along. Podman is the container engine used for the examples in this post. Substitute docker
in place of podman
in commands if you’re using Docker instead of Podman.
Building a Node.js application container image
The Node.js documentation provides a great overview of how to configure a Containerfile (also known as a Dockerfile) for a basic Node.js application. Let’s use that as a template to create a container image for a Node.js application. This application will use the Fastify framework and TypeScript, but it’s worth noting that this guide is applicable to any Node.js web framework.
To get started, generate a new Fastify project that uses TypeScript using the Fastify CLI:
npx fastify-cli@5 generate --lang=ts nodejs-ts-basic # Change into the project directory and generate a package-lock.json cd nodejs-ts-basic npm i --package-lock-only
Create a Containerfile in the new nodejs-ts-basic
project directory with the following contents:
FROM registry.access.redhat.com/ubi8/nodejs-18 WORKDIR /usr/src/app # Copy in package.json and package-lock.json COPY --chown=1001:1001 package*.json ./ # Install dependencies and devDependencies RUN npm ci # Copy in source code and other assets COPY --chown=1001:1001 . . # Compile the source TS into JS files RUN npm run build:ts # Configure fastify behaviour, and NODE_ENV ENV NODE_ENV=production ENV FASTIFY_PORT 8080 ENV FASTIFY_ADDRESS 0.0.0.0 EXPOSE 8080 # Set the fastify-cli binary as the entrypoint ENTRYPOINT [ "./node_modules/.bin/fastify" ] # Launch the container by passing these parameters to the entrypoint # These parameters can be overridden if you’d like CMD [ "start", "-l", "info", "dist/app.js" ]
Next, create a .dockerignore
file in the root of the repository. This works similar to a .gitignore
, but is respected by tools like Podman and Docker. It’s used to avoid copying the specified files into a container image:
# Change this as necessary for your own project(s) Containerfile* README.md dist node_modules test *.log .dockerignore .taprc .npmrc .env*
With these files in place, build a container image using the Podman (or Docker) CLI:
podman build . -f Containerfile -t nodejs-ts-basic
The resulting container image is approximately 831 MB in size. That's 188 MB larger than the UBI Node.js v18 base image! Investigate the size of files and folders by running the du
command inside the container:
podman run --rm nodejs-ts-basic /bin/du -h -d 1 60K ./dist 28K ./src 190M ./node_modules 190M .
Clearly the node_modules
folder is causing bloat in the image, because both the dependencies
and the devDependencies
specified in the package.json were installed.
Attempting to slim down the Node.js container image
A seemingly obvious solution to this problem is to remove those devDependencies
from the image. Try that by adding npm prune --omit=dev
after the npm run build:ts
command in the Containerfile:
RUN npm run build:ts RUN npm prune --omit=dev
And building it:
podman build . -f Containerfile -t nodejs-ts-basic-prune
The container image will be more lightweight, right? Let's check:
podman images --format '{{.Size}} {{.Repository}}' | grep nodejs 831 MB localhost/nodejs-ts-basic 832 MB localhost/nodejs-ts-basic-prune
This new image is larger than the last one! This is because container images are composed of layers. Each layer stores changes compared to the prior layer it's based on. The npm prune
command removed the devDependencies
from the final container image layer (you can confirm using the du
command shown previously), but the layer containing them is still there. Confirm this using the podman history localhost/nodejs-ts-basic-prune
command, noting that the npm ci layer is there, and is over 187 MB in size.
Multi-stage builds to the rescue
A great solution to this problem is to use a multi-stage build. Multi-stage builds perform some of the build steps in separate containers, and copy only what‘s needed into the final container image. This reduces the number of layers and overall size of the final container image.
This is an example of a multi-stage Containerfile that can be used to slim down the Node.js container image:
# First stage of the build is to install dependencies, and build from source FROM registry.access.redhat.com/ubi8/nodejs-18 as build WORKDIR /usr/src/app COPY --chown=1001:1001 package*.json ./ RUN npm ci COPY --chown=1001:1001 tsconfig*.json ./ COPY --chown=1001:1001 src src RUN npm run build:ts # Second stage of the build is to create a lighter container with just enough # required to run the application, i.e production deps and compiled js files FROM registry.access.redhat.com/ubi8/nodejs-18 WORKDIR /usr/src/app COPY --chown=1001:1001 --from=build /usr/src/app/package*.json/ . RUN npm ci --omit=dev COPY --chown=1001:1001 --from=build /usr/src/app/dist/ dist/ ENV NODE_ENV=production ENV FASTIFY_PORT 8080 ENV FASTIFY_ADDRESS 0.0.0.0 EXPOSE 8080 ENTRYPOINT [ "./node_modules/.bin/fastify" ] CMD [ "start", "-l", "info", "dist/app.js" ]
A summary of the two stages:
- The first stage (build) installs all dependencies, and compiles the TypeScript code.
- The second stage copies the compiled code from the build image and installs only the production dependencies to produce a deployable container image.
The initial build using this multi-stage Containerfile will be slower, but subsequent builds benefit from cached layers and are only a second or two slower than the single stage build.
The multi-stage build results in a container image that's just 661 MB. That’s 20% smaller than the single stage image’s 831 MB. This isn’t bad—but you can do better.
Going minimal
There’s one last step you can take to really slim this Node.js container image down; and that’s using a minimal base image. A minimal base image contains as few tools and libraries as possible, which means they have a significantly smaller footprint.
Modifying the second stage of the multi-stage build to use Red Hat's minimal Node.js v18 Universal Base Image reduces the final container image size to just 211 MB. All you need to do is change the FROM
statement to use the minimal image:
FROM registry.access.redhat.com/ubi8/nodejs-18-minimal
That 211 MB container image is 75% smaller than the first 831 MB container image you built! Not only is the image smaller, but it also has a lower risk profile since it doesn’t contain tools that could be used for malicious purposes in the event of a security breach.
Summary and next steps
Use the following tips to improve your container images for any application runtime:
- Use a trusted base image, such as Red Hat’s Universal Base Image.
- Don’t run as root. Using a Red Hat Universal Base Image takes care of this by default.
- Use multi-stage builds to minimize container image layers.
- Choose a minimal base image for the final stage in a multi-stage build.
- Use a
.dockerignore
file to keep unwanted files being copied into your container images. - Handle signals such as SIGINT and SIGTERM, or use tini or dumb-init to manage your process(es).
Take a look at this post by Bethany Griggs when you’re ready to deploy your lean Node.js container images on the Developer Sandbox for Red Hat OpenShift!
Last updated: August 14, 2023