Build the Docker image of a Node.js application

To deploy a Node.js application in production, you can execute the code directly and use a process manager like PM2 to monitor your application. A better approach is to create a Docker image and start a container from that image.

With Docker, you can tag your image and revert to a previous working image when the current has a critical bug; developers can run the application without worrying about the Operating System and configuration settings.

This post shows how to build a Docker image of a Node.js application. I also wrote a post about running the application with PM2.

Deploy a Node.js application with PM2 and Nginx
This tutorial will show how to deploy a Node.js application using PM2 and do a reverse proxy with Nginx to make the application accessible to the world.

Prerequisites

You need these tools installed on your computer to follow this tutorial.

Set up the project

We will use the Node.js REST API we built in the post below as an example of a Node.js application.

Document a Node.js REST API with Swagger and Open API
Most of the time, REST API is exposed to the world for developers to consume them. Document it is paramount for good integration. In this tutorial, we will see how to document a REST API built with Node.js and Express.

The source code of this Node.js REST API can be found in this GitHub repository; let's clone it locally using the Git sparse-checkout.


git clone --no-checkout https://github.com/tericcabrel/blog-tutorials.git

cd blog-tutorials

git sparse-checkout init --cone

git sparse-checkout set node-rest-api-swagger

git checkout @

The project is now available locally; let's make it work by running the following commands:


cd node-rest-api-swagger

docker run -d --rm -e MONGO_INITDB_ROOT_USERNAME=user -e MONGO_INITDB_ROOT_PASSWORD=secret -p 27017:27017 --name mongodb mongo:8.0

cp .env.example .env

yarn install

yarn start

The application will start on http://localhost:4500

Running the Node.js application locally.

Write the Dockerfile

We will use the Docker multi-stage to gain the following benefits:

  • Make the image build agnostic from any operating system because we can build the project in a Docker build stage and then copy the build files from another build stage.
  • Reduce the size of the Docker image buy copying only the output of a build stage.

At the root project directory, create a file called "Dockerfile" and add the code below:


FROM node:20-alpine AS builder

RUN mkdir -p /app

WORKDIR /app

COPY . .

RUN yarn install

RUN yarn tsc

FROM node:20-alpine AS app

ENV NODE_ENV=production

RUN mkdir -p /app

WORKDIR /app

COPY --chown=node:node --from=builder /app/package.json /app
COPY --chown=node:node --from=builder /app/build/ /app

RUN yarn install --frozen-lockfile --production

EXPOSE 4500

ENTRYPOINT ["node", "index.js"]

This file has two stages "builder" and "app" and use Node alpine 20 as the base image.

  • In the first stage builder, we copy the project file from the host, install the dependencies, and transpile the files from Typescript to JavaScript.
  • In the second stage app, we copy the package.json and the folder content generated by the project built in the previous stage.

    We also set the ownership of the files to the user node (automatically created in the Node Alpine image). Finally, we define the command to run when the container starts.

Run the command below to build the image:


docker build -t node-app .

View the image detail by running the command: docker image ls.

View the Docker image of the Node.js application.

Run the Docker image

To run the image and verify the application works as expected, we will create a Docker container from the image we build.

Since the Node project interacts with MongoDB, so we need to start a docker container for MongoDB with the command below:


docker network create node-app-network

docker run -d --network node-app-network -e MONGO_INITDB_ROOT_USERNAME=app_user -e MONGO_INITDB_ROOT_PASSWORD=app_password --name mongodb mongo:8.0

Update the environment variables file .env to set the database credentials; This file will be used to inject variables when starting a Docker container of our application image.


HOST=http://localhost
PORT=4500

DB_HOST=mongodb
DB_PORT=27017
DB_USER=app_user
DB_PASS=app_password
DB_NAME=admin

Run the command below to start the container of the project


docker run -it -p 4500:4500 --network node-app-network --name node-rest-api --rm --env-file .env node-app:latest

Open your browser and navigate to http://localhost:4500/documentation

Browse the documentation of the Node.js API running on Docker.

Reduce the size using Esbuild

After building the application, we still need the production dependencies to run the application, which is why the Docker image contains a node_modules folder.

Browse the Docker image content using Dive.

You can use a tool Dive to explore the content of a Docker image. We can see the node_modules folder takes 191MB of space.

We can reduce the size if we package the application into a single file using a Javascript bundler like ESbuild.

Here is what the Dockerfile now looks like:


FROM node:20-alpine AS builder

RUN mkdir -p /app

WORKDIR /app

COPY . .

RUN yarn install

RUN npx esbuild ./src/index.ts --bundle --platform=node --outfile=build/index.js

FROM node:20-alpine AS app

ENV NODE_ENV=production

RUN mkdir -p /app

WORKDIR /app

COPY --chown=node:node --from=builder /app/build/index.js /app

EXPOSE 4500

ENTRYPOINT ["node", "index.js"]

Run the following command to build a new Docker image:


docker build -t node-app.

Once done, view the Docker image size difference.

The Docker image size reduced after optimization.

We alamost reduced the size by three times. Start the container again and make it work.

💡
Caveat: One drawback of bundling everything into a single file is that we don't have a human-readable stack trace of an error or a log message.

To fix that, you can generate source maps of the file and package them in the Docker image, but this solution is not recommended as it slows the application.

I will show the recommended approach to fixing this issue in an upcoming post.

Wrap up

When building a Docker image, take advantage of multi-stage builds and use Esbuild to package your application in a single file when the size is critical (running on an AWS Lambda Function).

Using a tool liker Dive to explore the content of the Docker image can help reduce the Docker image size.

Your Docker image is ready to be deployed in production; checkout the following posts I wrote to go further:

You can find the code source on the GitHub repository.

Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.