Build A Production Ready Node/Express API With Docker

Mohammed Mwijaa
Oct 3 · 7 min read
Image for post
Image for post
image courtesy of dev.to

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:

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:

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:

FROM node:14-alpine
  • 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.json and 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.json and 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

These libraries are not required after the code is transpiled from TypeScript to JavaScript meaning they are just bloating our image. We are going to employ a multi-stage build to remove these. Now, multi-stage builds are another concept that probably need a separate article. Read more here:

Our multi-stage strategy will have two named stages: builder and final. The builder stage will:

  • Install all dependencies including TypeScript
  • Transpile the code into JavaScript and save it in a dist directory

The final stage will:

  • Copy the dist directory from the builder stage
  • Install ONLY the dependencies required in production
  • Use the copied JavaScript code to run the application

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:

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 express-typescript-api
  • The entrypoint is ./dist/index.js
  • 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:

Build a light-weight REST API with Node, Express and TypeScript

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 .

Summary

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:

Happy building!

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium