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.

A graph comparing the size of container images for a Node.js application depending on the build-strategy and base image used.
Figure 1: Image sizes resulting from a various build approaches using Red Hat's Universal Base Images for Node.js.

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:


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


# 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)

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

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:


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