A container is an isolated process running on a shared operating system. To run an application in a container, you require an image which is a “bundle” of your source code, runtime, libraries, configuration files and other required assets. This “bundle” ensures standardization and that only required assets are included. This makes containerized applications lightweight and very portable.
Docker is a technology that allows us to package and run applications as containers. With the recent rise in the need to build scalable distributed systems, containerization has become so mainstream that “Dockerize” is almost becoming a thing. This has also led to a rise of tools and frameworks built around containerization such as Docker Swarm and Kubernetes.
Welcome to the second part of a three part series on NodeJS and Express. Other articles in this series are:
- Build a light-weight REST API with Node, Express and TypeScript
- Deploy a Node and Express API on a bare metal server
Let’s Dockerize That Express API
As it turns out, creating a Docker image for an Express app is very easy. All you need is a Dockerfile. This is a text file containing an imperative set of instructions that tells Docker exactly how to build the image. First, let’s get the code we wrote in a previous article:
Getting started with Express and TypeScript. Contribute to MwinyiMoha/express-typescript-api development by creating an…
Let’s create a file called
Dockerfile — yes, without an extension. Then, add in the following as the contents:
Let’s build the Docker image by opening a terminal in the directory containing our code and running:
docker build -t <docker-username>/expressapi:v1 .
I am working under the assumption that you have a Docker account. If not, head over to Docker Hub and sign up for free. Substitute
<docker-username> with your real Docker username.
What A Terrible Example
The above Dockerfile yields an image that is:
- Very “heavy” weighing in at a whopping 1GB. Heavy images translate to slow deployments and can in some instances cost you money
- Slow to build as it does not take advantage of Docker’s caching mechanism
- Bloated with development dependencies
- Insecure and nowhere near production ready
While there are materials online that outline the above steps or something close to that, they are IMHO either for the absolute beginner, convenient short examples or outright terrible guides.
Doing It The Right Way
To alleviate some of the aforementioned concerns, there are a couple of considerations that we need to make if our image is to be production ready. These include:
- Docker’s Build Context
The build context is basically the directory in which the Dockerfile is located. During the build, all the recursive contents of files and directories are sent to Docker as the build context. This includes the contents of the notorious
node_modules directory which we do not need to send and therefore need to ignore. We use a
.dockerignore file for this:
touch .dockerignoreecho "node_modules/" >> .dockerignore
The next time we run the build, our context will in this example be around 60MB smaller.
- Starting With A Lightweight Base Image
In the previous build, we started with the
node:latest base image which is based off Debian(stretch) Linux and weighs in at around 940MB. In contrast,
node:14-alpine based off Alpine Linux weighs in at 117MB making it a viable lightweight candidate. Let’s update the first line in our Dockerfile to use this lighter image:
- Leveraging Docker’s Layer Caching Mechanism
Docker’s caching mechanism probably deserves an article on its own. In summary, Docker uses a layered approach “stacking” layer after layer to build out an image. If the instruction for a particular layer has not changed from a previous build, Docker will fetch the layer from cache instead of rebuilding it resulting into a faster build process. Let’s use this for installing NPM libraries:
COPY package*.json ./RUN npm iCOPY . .
In the above set up, we are copying
package-lock.json files before running the install command. We then copy the rest of the source code after installation of NPM libraries. This ensures that if the contents of
package-lock.json do not change, Docker will use a cached layer and skip reinstalling NPM libraries.
- Manage Dependencies Using A Multi-stage Build
If you remember, in the first article, we installed a couple of development dependencies using:
npm i -D typescript ts-node-dev @types/express
Use multi-stage builds
Multi-stage builds are a new feature requiring Docker 17.05 or higher on the daemon and client. Multistage builds are…
Our multi-stage strategy will have two named stages: builder and final. The builder stage will:
- Install all dependencies including TypeScript
The final stage will:
- Copy the
distdirectory from the builder stage
- Install ONLY the dependencies required in production
Our Dockerfile will now look like this:
Well, that’s some Dockerfile!
- Run The Application As A Non-root User
By default, Docker images run as root. This presents a security concern as the root user has escalated privileges. Rule of the thumb: run your docker images as a non-root user. Luckily, the
node:14-alpine image comes with a non-root user called
node with a home directory at
/home/node/. We are going to use this user to run the application after we ensure the user has ownership of the necessary files.
Let’s update our final stage:
- Use A Production Grade Process Manager
While the Node binary might suffice in development environments, the best way to run a Node app in production is by use of a process manager such as PM2. It offers features that come in handy to start, daemonize and manage a Node application. Read more about it here:
PM2 - Home
Guys just installed pm2 on my live server and hooked up to Keymetrics. Very impressed. Its all seamless and awesome…
One of the nice things about PM2 is that we can use a configuration file to declare the desired state for our application. Let’s create a
process.yml file in the root directory of our project with the following content:
The file tells PM2 that:
- We have one app called
- The entrypoint is
- The application should run in cluster mode, and
- Infer from the system the maximum number of workers to run
We will also need to install PM2 and use the
pm2-runtime suitable for containerized applications to run the app. Let’s once again update our final stage:
Since we are talking about a production grade process manager, we can also talk about NODE_ENV. Setting this to “production” as demonstrated by line 6 in our Dockerfile, improves the performance of the app and decreases verbosity of error messages which is typical production behavior.
Please note that sometimes we may not want to set the environment directly in the build step but specify it when running the container. In this case, you could omit the environment from Dockerfile and specify it at runtime as follows:
docker run -e NODE_ENV=production <image-name>
The above command gives us flexibility so that we can determine the application behavior under development or production settings
- Install Security Updates
The latest platform and security patches are vital to any application running in production. We will make sure our application base layer is up to date by installing updates using Alpine Linux’s
apk package manager:
RUN apk --no-cache -U upgrade
The command updates package indexes for the base image, upgrades installed packages and using the
--no-cache flag ensures that package index files are not downloaded into our image keeping it nice and slim.
Let’s Build The Image
Our final Dockerfile now looks like this:
Let’s build the image using:
docker build -t <docker-username>/expressapi:latest .
Our new image weighs in at an impressive 155 MB. And, remember that 117MB was our base image which means our source code, node packages, OS updates and configuration added a mere 38 MB to our total image size which is quite fair.
Since we are using a multi-stage strategy, we could target and build a specific stage. This allows us to build development versions of our images for inspection purposes. For instance, we could target the
builder stage using:
docker build --target builder -t <docker-username>/expressapi:dev .
To sum it all up, we’ve done quite a lot to make sure our image is at par with set standards for running containerized applications in production. Further tweaks and optimization could be done to make our image “better”.
For instance, if we chose to base our application on Node Version 10 — which is in LTS up to April 2021 — instead of version 14, we could shed an extra 33MB. It is, however, important to consider the trade-offs associated with using an older version.
An interesting tool called
FromLatest helps detect and tackle issues in Dockerfiles. Check it out here:
FROM:latest, An opinionated Dockerfile linter.
Review your Dockerfile to see if you have implemented the best practices.