Here is a tutorial that provides step-by-step guidance on how to write a Dockerfile for a NestJS project, using it as an example to create a production-optimized image.
This article is well-suited for various JavaScript projects, including Angular, React, Vue.js, and more. The code example provided is nearly identical across these projects.
Shall we go over the fundamentals?
A Dockerfile is a text file that contains instructions for building a Docker image. It is used to automate the process of creating containers using Docker.
The Dockerfile consists of instructions, each performing a specific action during the image build process. For example, in a Dockerfile, you can specify the base image, install dependencies, copy files, configure the environment, declare ports, and more.
Some key instructions in a Dockerfile include:
FROM
: Specifies the base image upon which the new image will be built.RUN
: Executes commands inside the container during the image build process.COPY
: Copies files and directories from the host machine to the container.WORKDIR
: Sets the working directory for subsequent instructions.EXPOSE
: Declares the ports that the container will listen on during runtime.CMD
: Specifies the command or executable that should run upon starting the container.
Here is a basic example of a Dockerfile.
# A Dockerfile must begin with a FROM instruction.
# New base image will be built based on the Node.js version 20
# Image that is based on Debian Linux.
FROM node:20
# Create app directory
WORKDIR /usr/src/app
# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package*.json ./
# Install app dependencies
RUN npm install
# Bundle app source
COPY . .
# Creates a "dist" folder with the production build
RUN npm run build
# Start the server using the production build
CMD [ "node", "dist/main.js" ]
Now, let’s perform local testing to verify the expected behavior of the Dockerfile. First, execute the following command in your terminal at the root of your project to build the image for
my-nest-app:
docker build -t my-nest-app .
Using a space and dot at the end of the docker build command is a convenient way to indicate the current directory as the build context if the Dockerfile is located in the same directory.
You can use the command docker images
in your terminal to confirm that the image has been created successfully. This command will display a list of Docker images stored on your local machine.
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
my-nest-app latest 014f7f222139 56 seconds ago 1.24GB
Let’s start the container and run the image with this command (be sure to use the same image name used above):
docker run -p80:3000 my-nest-app
All works, but you can see suspicious 1.24 GB. Come and get it! Just 1.24 GB! Just! / sarcasm.
Usually, the payment for a server in the cloud or hosting services is based on various factors, such as:
For example, check prices to use cloud servers such as AWS.
To proceed, we need to decrease the dimensions of the image. But before we copy any code, it’s crucial to have a firm grasp of Docker Basics. So let’s dive into it.
The FROM instruction should be the first instruction in the Dockerfile and is typically placed at the beginning of the file. It sets the foundation for further building the image, and all subsequent instructions in the Dockerfile will be applied to this base image.
Using FROM
allows you to create new images based on existing images and add and configure components, dependencies, and application-specific configurations.
So, if we do FROM node:20
let's take a look at what is included in the image. Inside the image, you will find the following:
/bin, /sbin, /usr/bin, /usr/sbin: Directories containing operating system executables. /etc: Directory with configuration files. /lib, /usr/lib: Directories with operating system libraries. /var: Directory for variable data such as logs, temporary files, etc.
Node.js: /usr/local/bin: Directory where Node.js executables such as node and npm are located. /usr/local/lib/node_modules: Directory where Node.js packages installed for the project reside.
Project Files: /app Or another directory specified in the Dockerfile: Directory where your project is located. This could be the working directory where project files are copied from the host system during the image build process.
Including the operating system in a Docker image or build image creates an isolated, portable environment for running applications. In addition, the operating system provides the necessary infrastructure and resources for application execution within a container or virtual machine, ensuring compatibility and stable application execution across different environments.
Clear? Clear. So, now we understand that in our image, we have OS and Node.js with various libraries even if we don’t use them.
The bad news is that the lion’s share of the image is OS and Node.js. So your application will be tiny compared to this LION. Or WHALE.
The good news is that Node provides many images for the most popular OS. So we need to choose what suits us.
Let’s look at what’s in the box of our Node.
Node.js on Debian: A Node.js build based on the Debian Linux distribution, providing compatibility with other Debian components and packages.
Node.js on Alpine Linux: A lightweight Node.js build running on Alpine Linux, known for its compact size and fast boot time.
Node.js on Ubuntu: A Node.js build based on the Ubuntu Linux distribution, one of the most popular Linux distributions with extensive support and a large community.
Node.js on Windows: A Node.js build specifically designed to run on Windows operating systems, optimized for Windows environment features.
Also, for Linux, we can select some subversion:
alpine: Lightweight Linux distribution.
current: The latest, up-to-date version of the software.
bullseye: Code name for Debian 11.
slim: Stripped-down version of distribution with minimal package set.
buster: Code name for Debian 10.
hydrogen: Deprecated version of Node.js (0.10.x).
lts: Long-Term Support version with extended maintenance.
Also, NodeJS has a combination of versions, like 20-bullseye-slim , current-buster-slim etc. Full list of versions with image size: https://hub.docker.com/_/node
Node provides a format for versions.
# Format example
node:<version>-<subversion>
# Real Example
FROM node:20-alpine
Our goal is to decrease image size, and the most suitable tool for this task is the fatless alpine, although there are some reservations to consider.
So, please return to our docker file. We need to change the version from FROM node:20
to FROM node:20-alpine
.
Let's re-run the commands to build the image and check your container out:
docker build -t nest-cloud-run .
docker run -p80:3000 nest-cloud-run
Let’s rundocker images
and … drumroll… you see significant changes:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
my-nest-app latest 004f7f222139 31 seconds ago 188MB
And we have 188MB. Enjoy! Applause. The curtain falls. We are all satisfied.
Is it the end? No!
For small run-and-gun applications, it’s the best-case scenario. But in my current project, I encountered customer security requirements:
This means updating the container, downloading the Sonar Scanner, unzipping the file, and running this application.
RUN apk update && apk upgrade --no-cache
RUN curl -u ....
RUN unzip ....
RUN /sonar-scanner/bin/sonar-scanner
But immediately, we have a problem.
/bin/sh : curl : not found
/bin/sh : zip : not found
The reason is apparent: alpine is fatless, and the cause only has a few libraries inside that can help you build projects without headaches.
In our case, the alpine under the box doesn't have a curl or zip. Spoiler: Also, we need Java’s openjdk8-jre to run sonar.
There are two methods to approach this situation:
I have a set of instructions outlining what needs to be done.
At the last moment, we would like to perform a magic trick and transform the image of node-20 from an unimportant elephant to a small ant called "alpine". Will it work? Certainly
The logic of your docker will be like this:
###################
# BUILD FOR PRODUCTION
###################
# Base image for production
FROM node:20 as build
# ... your build instructions here
###################
# PRODUCTION
###################
# Base image for production
FROM node:20-alpine as production
# ... your production instructions here
Real Dockerfile:
###################
# BUILD FOR PRODUCTION
###################
FROM node:20 as build
WORKDIR /usr/src/app
COPY --chown=node:node package*.json ./
# In order to run `npm run build` we need access to the Nest CLI which is a dev dependency. In the previous development stage we ran `npm ci` which installed all dependencies, so we can copy over the node_modules directory from the development image
COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules
COPY --chown=node:node . .
# Run the build command which creates the production bundle
RUN npm run build
# Set NODE_ENV environment variable
ENV NODE_ENV production
# Running `npm ci` removes the existing node_modules directory and
# passing in --only=production ensures that only the production dependencies
# are installed. This ensures that the node_modules directory is as
# optimized as possible.
RUN npm ci --only=production && npm cache clean --force
###################
# PRODUCTION
###################
FROM node:20-alpine as production
# Copy the bundled code from the build stage to the production image
COPY --from=build /app/dist /app/dist
COPY --from=build /app/libs /app/libs
COPY --from=build /app/node_modules /app/node_modules
# Start the server using the production build
CMD [ "node", "dist/main.js" ]
You may ask, “Where does the first full version of the Node go?”.
After all, did you notice that in the Dockerfile, we didn’t remove the node of the first version? So how?
If the FROM
instruction is used a second time in a Dockerfile, so you are starting a new build stage based on a different base image. In this case, the previous version of the image specified in the first FROM
instruction will no longer be used in the subsequent build stages.
In Docker, each FROM instruction starts a new build stage, which is a separate layer representing changes to the image's file system. When you reuse the FROM instruction, a new build stage begins with the base image specified in the second FROM instruction, and the previous layer with the previous image is no longer used.
This means that the previous version of the image and its layers still exist in the Docker file system, but they no longer affect the subsequent build stages. If you want to completely remove the previous version of the image and all its layers, you can perform a cleanup of unused layers using the docker image prune
command.
Also popular to set up docker to clean itself up on a schedule.
Gilbert’s law: The biggest problem at work is that none tells you what to do.
For this situation, we won't deal with different versions. Instead, we'll focus on strengthening specific areas using alpine only.
But now, we are responsible for determining the necessary packages to meet our requirements and including them in the file. It can often feel like a shot in the dark. For example, I encountered that Sonar needs Java and other packages.
FROM node:20-alpine
###################
# This command performs the update and upgrade of Alpine Linux packages.
# Use "apk update" to get the latest package index,
# and "apk upgrade" to update installed packages.
# Add the "--no-cache" flag when building a Docker image
# to avoid unnecessary caching and reduce image size.
###################
RUN apk update && apk upgrade --no-cache
RUN apk add --no-cache \
curl \
unzip \
libc6-compat \
openjdk8-jre
###################
# AFTER BUILD FOR PRODUCTION
###################
RUN curl -u ....
RUN unzip ....
RUN /sonar-scanner/bin/sonar-scanner
I dedicated a significant amount of time to figuring out the necessary packages 🤔, but it was worth it. I decreased the build process time by a few minutes 🕐 and reduced the number of files in Docker compared to the Lazy and Quick approach. As a result, Docker won’t have to work too hard — removing my files twice. And for now, I have already saved several hours ⏰ and my “time is money” account keeps getting bigger.