Dockerizing a Node.js application makes it possible to run your app on different machines in a reproducible environment. This means that the Docker software will package your application with all the required dependencies so that it can be run inside a container. There are many walkthroughs online describing how to easily Dockerize a Node.js application. There is, however, not much focus on how we can reduce the resulting image size. I would like to zoom in on this particular aspect of the containerization process.
Why is the image size important?
Not keeping an eye on the created size of your containerized application can result in unforeseen consequences. Let's take a look at how larger Docker image sizes can have negative effects.
- Development time
If you made changes to your application, you would like to test it out by containerizing it. This means that you build the Docker image on your local machine and this build time can increase for larger images. If the build time takes 10 minutes, for example, you'd also have to wait 10 minutes before you can get feedback from using this newly built image. Imagine doing this multiple times a day for minor code changes. The wait and build time will add up in the long run.
- Storage costs
It's no surprise that larger images will take up more space. On your local machine you might not notice it if you have a large storage drive. However, you're probably going to be using a CI/CD pipeline to build these Docker images and then publish them to a central repository like Docker Hub. Having larger images will impact you in two ways. It takes more time to transfer these images from the CI/CD pipeline to the repository. Having all these large images in the repository will also result in more costs to store them all. This is especially important for projects under active development.
Create a baseline Node.js app
Let's create a baseline Node.js app using the NestJS framework. We will create a simple demo app and containerize it with Docker. Afterward, we will apply a few techniques to improve the baseline setup and reduce our image size.
Make sure you have the following installed to get started:
- Node.js version 17
- IDE of choice (e.g. Visual Studio Code)
Open up a terminal and get started with a new NestJS project:
npm i -g @nestjs/cli nest new nest-docker && cd nest-docker npm i
Create a Dockerfile with the following contents:
To exclude node_modules from your Dockerfile, create a .dockerignore file with the following contents:
Start Docker and run the following command:
docker build . -t nest-docker
After the build has been made, you can check your created image with:
We can see that the image has a size of 1.17 GB. Now that we have created a baseline Docker image, let's see how we can reduce the image size.
1. Choose a different Node base image
There are a lot of base Node.js images that you can choose from. These can be found on the official Docker Hub page of Node.js. In our initial Dockerfile, we've chosen the node:17 base image. This node:17 base image incorporates a lot of dependencies from the underlying OS that you most likely don't need. We can try to use the node:17-slim version and check if that fits our needs. This is the resulting Dockerfile:
Let's rebuild the image and check the result:
We see immediate results by choosing a different and leaner Node.js image. We've gone from 1.17 GB to 464 MB.
2. Reduce development dependencies
During the development cycle of an application, we need a bunch of dependencies. Among these dependencies, there's a special category called devDependencies that are only needed during development time and not necessarily for running our application. These development dependencies are not needed anymore, once the application has been built for production.
To reduce the development dependencies in our Dockerfile, we're going to make use of a concept called multi-stage Docker builds. This makes it possible for us to divide the build of the image into two stages which we will call development and production. In the development stage, we install all the dependencies required to build our application. This includes the development dependencies. Afterward, we enter the production stage, in which from the development stage we pass on only the artifacts required for running our image to the production stage. The artifacts that we need for running the application are package.json, package-lock.json, and the dist folder. We can pass these three artifacts like this:
COPY --from=DEVELOPMENT /usr/src/app/dist ./dist COPY --from=DEVELOPMENT /usr/src/app/package.json ./package.json COPY --from=DEVELOPMENT /usr/src/app/package-lock.json ./package-lock.json
We haven't passed on the node_modules directory to our production stage. This is required for running our NestJS application. But node_modules also contain development dependencies that make our image larger than needed. One extra thing needed during our production stage is to install the dependencies with a production flag:
RUN npm ci --production
The resulting Dockerfile looks like this:
Let's rebuild our image and check the result:
With this multi-stage build, we have been able to reduce our Docker image size from 464 MB to 253 MB. That's an additional 45% reduction.
I have covered two simple methods for reducing your Docker image size. The first one is choosing a slimmer base image, which is the simplest approach. The second one requires a bit more understanding of what is purely required during the production runtime. This, however, shouldn't prevent you from also applying the second method. By applying both techniques, we've been able to reduce our baseline Docker image from 1.17 GB to a whopping 253 MB. A reduction size of 78% is certainly impactful in the long run for both development time and storage costs.
The full source code of this project can be found here.
If the content was helpful, feel free to support me here:
Top comments (0)